Fossil SCM

Merge in trunk.

stephan 2026-05-25 15:20 UTC forum-statuses merge
Commit d6ff83b158ddc9bd06461555579a13ac225d6cdde32e2d1bf947e272c949c3f0
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,15 @@
11131113
11141114
div.setup_forum-column {
11151115
display: flex;
11161116
flex-direction: column;
11171117
}
1118
+
1119
+body.forum div.forumPosts table tr.sticky > td:nth-child(1):before {
1120
+ padding-right: 0.25em;
1121
+ content: "📌";
1122
+}
11181123
11191124
body.cpage-setup_forum > .content table {
11201125
margin-bottom: 1em;
11211126
}
11221127
body.cpage-setup_forum > .content table.bordered {
11231128
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,15 @@
1113
1114 div.setup_forum-column {
1115 display: flex;
1116 flex-direction: column;
1117 }
 
 
 
 
 
1118
1119 body.cpage-setup_forum > .content table {
1120 margin-bottom: 1em;
1121 }
1122 body.cpage-setup_forum > .content table.bordered {
1123
--- src/default.css
+++ src/default.css
@@ -1113,10 +1113,15 @@
1113
1114 div.setup_forum-column {
1115 display: flex;
1116 flex-direction: column;
1117 }
1118
1119 body.forum div.forumPosts table tr.sticky > td:nth-child(1):before {
1120 padding-right: 0.25em;
1121 content: "📌";
1122 }
1123
1124 body.cpage-setup_forum > .content table {
1125 margin-bottom: 1em;
1126 }
1127 body.cpage-setup_forum > .content table.bordered {
1128
+98 -32
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378378
int trid; /* RID of new control artifact */
379379
char *zUuid; /* UUID of head version of post */
380380
381381
db_begin_transaction();
382382
frid = forumpost_head_rid(frid);
383
- iTagged = forum_rid_is_tagged(frid, "closed", 1);
383
+ iTagged = forum_rid_is_tagged(frid, zTagName, 1);
384384
if( (iTagged && addTag
385385
/* Already tagged, noting that in the case of (addTag<0) it may
386386
** actually be a parent which is tagged. */)
387387
|| (iTagged<=0 && !addTag
388388
/* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925925
static void forum_render_attachment_list2(ForumPost *p){
926926
if( p->pEditHead ) p = p->pEditHead;
927927
forum_render_attachment_list(p->zUuid);
928928
}
929929
930
+/* Flags for use with forum_display_post() */
931
+#define FDISPLAY_RAW 0x01 /* omit the border */
932
+#define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
933
+#define FDISPLAY_HISTORY 0x04 /* Showing edit history */
934
+#define FDISPLAY_SELECTED 0x08 /* This is the selected post */
935
+#define FDISPLAY_ISROOT 0x10 /* This is the root post */
936
+
930937
/*
931938
** Display a single post in a forum thread.
932939
*/
933940
static void forum_display_post(
934941
ForumThread *pThread, /* The thread that this post is a member of */
935942
ForumPost *p, /* Forum post to display */
936943
int iIndentScale, /* Indent scale factor */
937
- int bRaw, /* True to omit the border */
938
- int bUnf, /* True to leave the post unformatted */
939
- int bHist, /* True if showing edit history */
940
- int bSelect, /* True if this is the selected post */
944
+ int flags, /* From the FDISPLAY_... enum */
941945
char *zQuery /* Common query string */
942946
){
943947
char *zPosterName; /* Name of user who originally made this post */
944948
char *zEditorName; /* Name of user who provided the current edit */
945949
char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
948952
Manifest *pManifest; /* Manifest comprising the current post */
949953
int bPrivate; /* True for posts awaiting moderation */
950954
int bSameUser; /* True if author is also the reader */
951955
int iIndent; /* Indent level */
952956
int iClosed; /* True if (sub)thread is closed */
957
+ const int bRaw = flags & FDISPLAY_RAW;
958
+ const int bUnf = flags & FDISPLAY_UNFORMATTED;
959
+ const int bHist = flags & FDISPLAY_HISTORY;
960
+ const int bSelect = flags & FDISPLAY_SELECTED;
953961
const char *zMimetype;/* Formatting MIME type */
954962
955963
/* Get the manifest for the post. Abort if not found (e.g. shunned). */
956964
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
957965
if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
11201128
const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
11211129
if( forumpost_may_close() && iClosed>=0 ){
11221130
@ <form method="post" \
11231131
@ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
11241132
login_insert_csrf_secret();
1125
- @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1133
+ @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
11261134
if( moderation_pending(p->fpid)==0 ){
11271135
@ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1128
- @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
1136
+ @ class='hidden %s(iClosed ? "action-reopen" : "action-close")'/>
1137
+ /* ^^^ activated by fossil.page.forumpost.js */
11291138
}
11301139
@ </form>
11311140
}
11321141
if( g.perm.Admin ||
11331142
(login_is_individual()
@@ -1140,10 +1149,21 @@
11401149
@ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
11411150
@ <input type="submit" value="Attach...">
11421151
login_insert_csrf_secret();
11431152
moderation_pending_www(p->fpid);
11441153
@ </form>
1154
+ }
1155
+ if( !p->pIrt && g.perm.Setup ){
1156
+ const int isPinned = forum_rid_is_tagged(pHead->fpid, "pinned", 0);
1157
+ @ <form method="post" \
1158
+ @ action='%R/forumpost_%s(isPinned ? "unpin" : "pin")'>
1159
+ login_insert_csrf_secret();
1160
+ @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1161
+ @ <input type="button" value='%s(isPinned ? "Unpin" : "Pin")' \
1162
+ @ class='hidden %s(isPinned ? "action-unpin" : "action-pin")'/>
1163
+ /* ^^^ activated by fossil.page.forumpost.js */
1164
+ @ </form>
11451165
}
11461166
}
11471167
@ </div>
11481168
}
11491169
@ </div>
@@ -1259,12 +1279,19 @@
12591279
}
12601280
12611281
/* Display the appropriate subset of posts in sequence. */
12621282
while( p ){
12631283
/* Display the post. */
1264
- forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1265
- bUnf, bHist, p==pSelect, zQuery);
1284
+ forum_display_post(
1285
+ pThread, p, iIndentScale,
1286
+ (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1287
+ (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1288
+ (bHist ? FDISPLAY_HISTORY : 0) |
1289
+ (p==pSelect ? FDISPLAY_SELECTED : 0) |
1290
+ ((0==fpid || fpid==froot) ? FDISPLAY_ISROOT : 0),
1291
+ zQuery
1292
+ );
12661293
12671294
/* Advance to the next post in the thread. */
12681295
if( mode==FD_CHRONO ){
12691296
/* Chronological mode: display posts (optionally including edits) in their
12701297
** original commit order. */
@@ -1634,10 +1661,29 @@
16341661
mimetype_option_menu(zMimetype, "mimetype");
16351662
@ <div class="forum-editor-widget">
16361663
@ <textarea aria-label="Content:" name="content" class="wikiedit" \
16371664
@ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
16381665
}
1666
+
1667
+/*
1668
+** Internal helper for /forumpost_XYZ internal pages which tag/untag
1669
+** posts.
1670
+*/
1671
+static void forumpost_action_helper(const char *zTag, const char *zVal,
1672
+ int addTag){
1673
+ const char *zFpid = PD("fpid","");
1674
+ int fpid;
1675
+
1676
+ cgi_csrf_verify();
1677
+ fpid = symbolic_name_to_rid(zFpid, "f");
1678
+ if( fpid<=0 ){
1679
+ webpage_error("Missing or invalid fpid query parameter");
1680
+ }
1681
+ forumpost_tag(fpid, zTag, addTag, zVal);
1682
+ cgi_redirectf("%R/forumpost/%S",zFpid);
1683
+ return;
1684
+}
16391685
16401686
/*
16411687
** WEBPAGE: forumpost_close hidden
16421688
** WEBPAGE: forumpost_reopen hidden
16431689
**
@@ -1648,30 +1694,39 @@
16481694
** API for forumpost_tag(). After (perhaps) modifying the "closed"
16491695
** status of the given thread, it redirects to that post's thread
16501696
** view. Requires admin privileges.
16511697
*/
16521698
void forum_page_close(void){
1653
- const char *zFpid = PD("fpid","");
1654
- const char *zReason = 0;
1655
- int fClose;
1656
- int fpid;
1657
-
16581699
login_check_credentials();
16591700
if( forumpost_may_close()==0 ){
16601701
login_needed(g.anon.Admin);
1661
- return;
1662
- }
1663
- cgi_csrf_verify();
1664
- fpid = symbolic_name_to_rid(zFpid, "f");
1665
- if( fpid<=0 ){
1666
- webpage_error("Missing or invalid fpid query parameter");
1667
- }
1668
- fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1669
- if( fClose ) zReason = PD("reason",0);
1670
- forumpost_tag(fpid, "closed", fClose, zReason);
1671
- cgi_redirectf("%R/forumpost/%S",zFpid);
1672
- return;
1702
+ }else{
1703
+ const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1704
+ char const *zReason = bIsAdd ? 0 : PD("reason", 0);
1705
+ forumpost_action_helper("closed", zReason, bIsAdd);
1706
+ }
1707
+}
1708
+
1709
+/*
1710
+** WEBPAGE: forumpost_pin hidden
1711
+** WEBPAGE: forumpost_unpin hidden
1712
+**
1713
+** fpid=X Hash of the post to be edited. REQUIRED
1714
+**
1715
+** Pins or unpins the given forum post, within the bounds of the
1716
+** API for forumpost_tag(). After (perhaps) modifying the "pinned"
1717
+** tag of the given thread, it redirects to that post's thread
1718
+** view. Requires setup privileges.
1719
+*/
1720
+void forum_page_pin(void){
1721
+ login_check_credentials();
1722
+ if( !g.perm.Setup ){
1723
+ login_needed(g.anon.Setup);
1724
+ }else{
1725
+ const int bIsAdd = sqlite3_strglob("*_pin*", g.zPath)==0;
1726
+ forumpost_action_helper("pinned", 0, bIsAdd);
1727
+ }
16731728
}
16741729
16751730
/*
16761731
** WEBPAGE: forumnew
16771732
** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
22412296
style_submenu_entry("n","Max:",4,0);
22422297
iOfst = atoi(PD("x","0"));
22432298
iCnt = 0;
22442299
if( db_table_exists("repository","forumpost") ){
22452300
db_prepare(&q,
2246
- "WITH thread(age,duration,cnt,root,last) AS ("
2301
+ "WITH thread(age,duration,cnt,root,last,sticky) AS ("
22472302
" SELECT"
22482303
" julianday('now') - max(fmtime),"
22492304
" max(fmtime) - min(fmtime),"
22502305
" sum(fprev IS NULL),"
22512306
" froot,"
22522307
" (SELECT fpid FROM forumpost AS y"
22532308
" WHERE y.froot=x.froot %s"
2254
- " ORDER BY y.fmtime DESC LIMIT 1)"
2309
+ " ORDER BY y.fmtime DESC LIMIT 1),"
2310
+ " CASE WHEN"
2311
+ " firt IS NULL AND"
2312
+ " (SELECT 1 FROM tagxref ref, tag t"
2313
+ " WHERE ref.rid=x.fpid AND ref.tagtype>0"
2314
+ " AND ref.tagid=t.tagid"
2315
+ " AND t.tagname='pinned')"
2316
+ " THEN 1"
2317
+ " ELSE 0"
2318
+ " END"
22552319
" FROM forumpost AS x"
22562320
" WHERE %s"
22572321
" GROUP BY froot"
2258
- " ORDER BY 1 LIMIT %d OFFSET %d"
2322
+ " ORDER BY 6 DESC, 1 LIMIT %d OFFSET %d"
22592323
")"
22602324
"SELECT"
22612325
" thread.age," /* 0 */
22622326
" thread.duration," /* 1 */
22632327
" thread.cnt," /* 2 */
22642328
" blob.uuid," /* 3 */
22652329
" substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2266
- " thread.last" /* 5 */
2330
+ " thread.last," /* 5 */
2331
+ " thread.sticky" /* 6 */
22672332
" FROM thread, blob, event"
22682333
" WHERE blob.rid=thread.last"
22692334
" AND event.objid=thread.last"
2270
- " ORDER BY 1;",
2335
+ " ORDER BY 7 DESC, 1;",
22712336
g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
22722337
g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
22732338
iLimit+1, iOfst
22742339
);
22752340
while( db_step(&q)==SQLITE_ROW ){
22762341
char *zAge = human_readable_age(db_column_double(&q,0));
22772342
int nMsg = db_column_int(&q, 2);
2343
+ int bSticky = db_column_int(&q, 6);
22782344
const char *zUuid = db_column_text(&q, 3);
22792345
const char *zTitle = db_column_text(&q, 4);
22802346
if( iCnt==0 ){
22812347
if( iOfst>0 ){
22822348
@ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
23012367
@ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
23022368
@ &darr; Older...</a></td></tr>
23032369
fossil_free(zAge);
23042370
break;
23052371
}
2306
- @ <tr><td>%h(zAge) ago</td>
2372
+ @ <tr%s(bSticky ? " class='sticky'" : "")><td>%h(zAge) ago</td>
23072373
@ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
23082374
@ <td>\
23092375
if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
23102376
@ <span class="modpending">\
23112377
@ Awaiting Moderator Approval</span><br>
23122378
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378 int trid; /* RID of new control artifact */
379 char *zUuid; /* UUID of head version of post */
380
381 db_begin_transaction();
382 frid = forumpost_head_rid(frid);
383 iTagged = forum_rid_is_tagged(frid, "closed", 1);
384 if( (iTagged && addTag
385 /* Already tagged, noting that in the case of (addTag<0) it may
386 ** actually be a parent which is tagged. */)
387 || (iTagged<=0 && !addTag
388 /* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925 static void forum_render_attachment_list2(ForumPost *p){
926 if( p->pEditHead ) p = p->pEditHead;
927 forum_render_attachment_list(p->zUuid);
928 }
929
 
 
 
 
 
 
 
930 /*
931 ** Display a single post in a forum thread.
932 */
933 static void forum_display_post(
934 ForumThread *pThread, /* The thread that this post is a member of */
935 ForumPost *p, /* Forum post to display */
936 int iIndentScale, /* Indent scale factor */
937 int bRaw, /* True to omit the border */
938 int bUnf, /* True to leave the post unformatted */
939 int bHist, /* True if showing edit history */
940 int bSelect, /* True if this is the selected post */
941 char *zQuery /* Common query string */
942 ){
943 char *zPosterName; /* Name of user who originally made this post */
944 char *zEditorName; /* Name of user who provided the current edit */
945 char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
948 Manifest *pManifest; /* Manifest comprising the current post */
949 int bPrivate; /* True for posts awaiting moderation */
950 int bSameUser; /* True if author is also the reader */
951 int iIndent; /* Indent level */
952 int iClosed; /* True if (sub)thread is closed */
 
 
 
 
953 const char *zMimetype;/* Formatting MIME type */
954
955 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
956 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
957 if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
1120 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1121 if( forumpost_may_close() && iClosed>=0 ){
1122 @ <form method="post" \
1123 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1124 login_insert_csrf_secret();
1125 @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1126 if( moderation_pending(p->fpid)==0 ){
1127 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1128 @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
 
1129 }
1130 @ </form>
1131 }
1132 if( g.perm.Admin ||
1133 (login_is_individual()
@@ -1140,10 +1149,21 @@
1140 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1141 @ <input type="submit" value="Attach...">
1142 login_insert_csrf_secret();
1143 moderation_pending_www(p->fpid);
1144 @ </form>
 
 
 
 
 
 
 
 
 
 
 
1145 }
1146 }
1147 @ </div>
1148 }
1149 @ </div>
@@ -1259,12 +1279,19 @@
1259 }
1260
1261 /* Display the appropriate subset of posts in sequence. */
1262 while( p ){
1263 /* Display the post. */
1264 forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1265 bUnf, bHist, p==pSelect, zQuery);
 
 
 
 
 
 
 
1266
1267 /* Advance to the next post in the thread. */
1268 if( mode==FD_CHRONO ){
1269 /* Chronological mode: display posts (optionally including edits) in their
1270 ** original commit order. */
@@ -1634,10 +1661,29 @@
1634 mimetype_option_menu(zMimetype, "mimetype");
1635 @ <div class="forum-editor-widget">
1636 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1637 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1638 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1639
1640 /*
1641 ** WEBPAGE: forumpost_close hidden
1642 ** WEBPAGE: forumpost_reopen hidden
1643 **
@@ -1648,30 +1694,39 @@
1648 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1649 ** status of the given thread, it redirects to that post's thread
1650 ** view. Requires admin privileges.
1651 */
1652 void forum_page_close(void){
1653 const char *zFpid = PD("fpid","");
1654 const char *zReason = 0;
1655 int fClose;
1656 int fpid;
1657
1658 login_check_credentials();
1659 if( forumpost_may_close()==0 ){
1660 login_needed(g.anon.Admin);
1661 return;
1662 }
1663 cgi_csrf_verify();
1664 fpid = symbolic_name_to_rid(zFpid, "f");
1665 if( fpid<=0 ){
1666 webpage_error("Missing or invalid fpid query parameter");
1667 }
1668 fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1669 if( fClose ) zReason = PD("reason",0);
1670 forumpost_tag(fpid, "closed", fClose, zReason);
1671 cgi_redirectf("%R/forumpost/%S",zFpid);
1672 return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1673 }
1674
1675 /*
1676 ** WEBPAGE: forumnew
1677 ** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
2241 style_submenu_entry("n","Max:",4,0);
2242 iOfst = atoi(PD("x","0"));
2243 iCnt = 0;
2244 if( db_table_exists("repository","forumpost") ){
2245 db_prepare(&q,
2246 "WITH thread(age,duration,cnt,root,last) AS ("
2247 " SELECT"
2248 " julianday('now') - max(fmtime),"
2249 " max(fmtime) - min(fmtime),"
2250 " sum(fprev IS NULL),"
2251 " froot,"
2252 " (SELECT fpid FROM forumpost AS y"
2253 " WHERE y.froot=x.froot %s"
2254 " ORDER BY y.fmtime DESC LIMIT 1)"
 
 
 
 
 
 
 
 
 
2255 " FROM forumpost AS x"
2256 " WHERE %s"
2257 " GROUP BY froot"
2258 " ORDER BY 1 LIMIT %d OFFSET %d"
2259 ")"
2260 "SELECT"
2261 " thread.age," /* 0 */
2262 " thread.duration," /* 1 */
2263 " thread.cnt," /* 2 */
2264 " blob.uuid," /* 3 */
2265 " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2266 " thread.last" /* 5 */
 
2267 " FROM thread, blob, event"
2268 " WHERE blob.rid=thread.last"
2269 " AND event.objid=thread.last"
2270 " ORDER BY 1;",
2271 g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
2272 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2273 iLimit+1, iOfst
2274 );
2275 while( db_step(&q)==SQLITE_ROW ){
2276 char *zAge = human_readable_age(db_column_double(&q,0));
2277 int nMsg = db_column_int(&q, 2);
 
2278 const char *zUuid = db_column_text(&q, 3);
2279 const char *zTitle = db_column_text(&q, 4);
2280 if( iCnt==0 ){
2281 if( iOfst>0 ){
2282 @ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
2301 @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2302 @ &darr; Older...</a></td></tr>
2303 fossil_free(zAge);
2304 break;
2305 }
2306 @ <tr><td>%h(zAge) ago</td>
2307 @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2308 @ <td>\
2309 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2310 @ <span class="modpending">\
2311 @ Awaiting Moderator Approval</span><br>
2312
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378 int trid; /* RID of new control artifact */
379 char *zUuid; /* UUID of head version of post */
380
381 db_begin_transaction();
382 frid = forumpost_head_rid(frid);
383 iTagged = forum_rid_is_tagged(frid, zTagName, 1);
384 if( (iTagged && addTag
385 /* Already tagged, noting that in the case of (addTag<0) it may
386 ** actually be a parent which is tagged. */)
387 || (iTagged<=0 && !addTag
388 /* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925 static void forum_render_attachment_list2(ForumPost *p){
926 if( p->pEditHead ) p = p->pEditHead;
927 forum_render_attachment_list(p->zUuid);
928 }
929
930 /* Flags for use with forum_display_post() */
931 #define FDISPLAY_RAW 0x01 /* omit the border */
932 #define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
933 #define FDISPLAY_HISTORY 0x04 /* Showing edit history */
934 #define FDISPLAY_SELECTED 0x08 /* This is the selected post */
935 #define FDISPLAY_ISROOT 0x10 /* This is the root post */
936
937 /*
938 ** Display a single post in a forum thread.
939 */
940 static void forum_display_post(
941 ForumThread *pThread, /* The thread that this post is a member of */
942 ForumPost *p, /* Forum post to display */
943 int iIndentScale, /* Indent scale factor */
944 int flags, /* From the FDISPLAY_... enum */
 
 
 
945 char *zQuery /* Common query string */
946 ){
947 char *zPosterName; /* Name of user who originally made this post */
948 char *zEditorName; /* Name of user who provided the current edit */
949 char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
952 Manifest *pManifest; /* Manifest comprising the current post */
953 int bPrivate; /* True for posts awaiting moderation */
954 int bSameUser; /* True if author is also the reader */
955 int iIndent; /* Indent level */
956 int iClosed; /* True if (sub)thread is closed */
957 const int bRaw = flags & FDISPLAY_RAW;
958 const int bUnf = flags & FDISPLAY_UNFORMATTED;
959 const int bHist = flags & FDISPLAY_HISTORY;
960 const int bSelect = flags & FDISPLAY_SELECTED;
961 const char *zMimetype;/* Formatting MIME type */
962
963 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
964 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
965 if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
1128 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1129 if( forumpost_may_close() && iClosed>=0 ){
1130 @ <form method="post" \
1131 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1132 login_insert_csrf_secret();
1133 @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1134 if( moderation_pending(p->fpid)==0 ){
1135 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1136 @ class='hidden %s(iClosed ? "action-reopen" : "action-close")'/>
1137 /* ^^^ activated by fossil.page.forumpost.js */
1138 }
1139 @ </form>
1140 }
1141 if( g.perm.Admin ||
1142 (login_is_individual()
@@ -1140,10 +1149,21 @@
1149 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1150 @ <input type="submit" value="Attach...">
1151 login_insert_csrf_secret();
1152 moderation_pending_www(p->fpid);
1153 @ </form>
1154 }
1155 if( !p->pIrt && g.perm.Setup ){
1156 const int isPinned = forum_rid_is_tagged(pHead->fpid, "pinned", 0);
1157 @ <form method="post" \
1158 @ action='%R/forumpost_%s(isPinned ? "unpin" : "pin")'>
1159 login_insert_csrf_secret();
1160 @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1161 @ <input type="button" value='%s(isPinned ? "Unpin" : "Pin")' \
1162 @ class='hidden %s(isPinned ? "action-unpin" : "action-pin")'/>
1163 /* ^^^ activated by fossil.page.forumpost.js */
1164 @ </form>
1165 }
1166 }
1167 @ </div>
1168 }
1169 @ </div>
@@ -1259,12 +1279,19 @@
1279 }
1280
1281 /* Display the appropriate subset of posts in sequence. */
1282 while( p ){
1283 /* Display the post. */
1284 forum_display_post(
1285 pThread, p, iIndentScale,
1286 (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1287 (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1288 (bHist ? FDISPLAY_HISTORY : 0) |
1289 (p==pSelect ? FDISPLAY_SELECTED : 0) |
1290 ((0==fpid || fpid==froot) ? FDISPLAY_ISROOT : 0),
1291 zQuery
1292 );
1293
1294 /* Advance to the next post in the thread. */
1295 if( mode==FD_CHRONO ){
1296 /* Chronological mode: display posts (optionally including edits) in their
1297 ** original commit order. */
@@ -1634,10 +1661,29 @@
1661 mimetype_option_menu(zMimetype, "mimetype");
1662 @ <div class="forum-editor-widget">
1663 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1664 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1665 }
1666
1667 /*
1668 ** Internal helper for /forumpost_XYZ internal pages which tag/untag
1669 ** posts.
1670 */
1671 static void forumpost_action_helper(const char *zTag, const char *zVal,
1672 int addTag){
1673 const char *zFpid = PD("fpid","");
1674 int fpid;
1675
1676 cgi_csrf_verify();
1677 fpid = symbolic_name_to_rid(zFpid, "f");
1678 if( fpid<=0 ){
1679 webpage_error("Missing or invalid fpid query parameter");
1680 }
1681 forumpost_tag(fpid, zTag, addTag, zVal);
1682 cgi_redirectf("%R/forumpost/%S",zFpid);
1683 return;
1684 }
1685
1686 /*
1687 ** WEBPAGE: forumpost_close hidden
1688 ** WEBPAGE: forumpost_reopen hidden
1689 **
@@ -1648,30 +1694,39 @@
1694 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1695 ** status of the given thread, it redirects to that post's thread
1696 ** view. Requires admin privileges.
1697 */
1698 void forum_page_close(void){
 
 
 
 
 
1699 login_check_credentials();
1700 if( forumpost_may_close()==0 ){
1701 login_needed(g.anon.Admin);
1702 }else{
1703 const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1704 char const *zReason = bIsAdd ? 0 : PD("reason", 0);
1705 forumpost_action_helper("closed", zReason, bIsAdd);
1706 }
1707 }
1708
1709 /*
1710 ** WEBPAGE: forumpost_pin hidden
1711 ** WEBPAGE: forumpost_unpin hidden
1712 **
1713 ** fpid=X Hash of the post to be edited. REQUIRED
1714 **
1715 ** Pins or unpins the given forum post, within the bounds of the
1716 ** API for forumpost_tag(). After (perhaps) modifying the "pinned"
1717 ** tag of the given thread, it redirects to that post's thread
1718 ** view. Requires setup privileges.
1719 */
1720 void forum_page_pin(void){
1721 login_check_credentials();
1722 if( !g.perm.Setup ){
1723 login_needed(g.anon.Setup);
1724 }else{
1725 const int bIsAdd = sqlite3_strglob("*_pin*", g.zPath)==0;
1726 forumpost_action_helper("pinned", 0, bIsAdd);
1727 }
1728 }
1729
1730 /*
1731 ** WEBPAGE: forumnew
1732 ** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
2296 style_submenu_entry("n","Max:",4,0);
2297 iOfst = atoi(PD("x","0"));
2298 iCnt = 0;
2299 if( db_table_exists("repository","forumpost") ){
2300 db_prepare(&q,
2301 "WITH thread(age,duration,cnt,root,last,sticky) AS ("
2302 " SELECT"
2303 " julianday('now') - max(fmtime),"
2304 " max(fmtime) - min(fmtime),"
2305 " sum(fprev IS NULL),"
2306 " froot,"
2307 " (SELECT fpid FROM forumpost AS y"
2308 " WHERE y.froot=x.froot %s"
2309 " ORDER BY y.fmtime DESC LIMIT 1),"
2310 " CASE WHEN"
2311 " firt IS NULL AND"
2312 " (SELECT 1 FROM tagxref ref, tag t"
2313 " WHERE ref.rid=x.fpid AND ref.tagtype>0"
2314 " AND ref.tagid=t.tagid"
2315 " AND t.tagname='pinned')"
2316 " THEN 1"
2317 " ELSE 0"
2318 " END"
2319 " FROM forumpost AS x"
2320 " WHERE %s"
2321 " GROUP BY froot"
2322 " ORDER BY 6 DESC, 1 LIMIT %d OFFSET %d"
2323 ")"
2324 "SELECT"
2325 " thread.age," /* 0 */
2326 " thread.duration," /* 1 */
2327 " thread.cnt," /* 2 */
2328 " blob.uuid," /* 3 */
2329 " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2330 " thread.last," /* 5 */
2331 " thread.sticky" /* 6 */
2332 " FROM thread, blob, event"
2333 " WHERE blob.rid=thread.last"
2334 " AND event.objid=thread.last"
2335 " ORDER BY 7 DESC, 1;",
2336 g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
2337 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2338 iLimit+1, iOfst
2339 );
2340 while( db_step(&q)==SQLITE_ROW ){
2341 char *zAge = human_readable_age(db_column_double(&q,0));
2342 int nMsg = db_column_int(&q, 2);
2343 int bSticky = db_column_int(&q, 6);
2344 const char *zUuid = db_column_text(&q, 3);
2345 const char *zTitle = db_column_text(&q, 4);
2346 if( iCnt==0 ){
2347 if( iOfst>0 ){
2348 @ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
2367 @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2368 @ &darr; Older...</a></td></tr>
2369 fossil_free(zAge);
2370 break;
2371 }
2372 @ <tr%s(bSticky ? " class='sticky'" : "")><td>%h(zAge) ago</td>
2373 @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2374 @ <td>\
2375 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2376 @ <span class="modpending">\
2377 @ Awaiting Moderator Approval</span><br>
2378
+98 -32
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378378
int trid; /* RID of new control artifact */
379379
char *zUuid; /* UUID of head version of post */
380380
381381
db_begin_transaction();
382382
frid = forumpost_head_rid(frid);
383
- iTagged = forum_rid_is_tagged(frid, "closed", 1);
383
+ iTagged = forum_rid_is_tagged(frid, zTagName, 1);
384384
if( (iTagged && addTag
385385
/* Already tagged, noting that in the case of (addTag<0) it may
386386
** actually be a parent which is tagged. */)
387387
|| (iTagged<=0 && !addTag
388388
/* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925925
static void forum_render_attachment_list2(ForumPost *p){
926926
if( p->pEditHead ) p = p->pEditHead;
927927
forum_render_attachment_list(p->zUuid);
928928
}
929929
930
+/* Flags for use with forum_display_post() */
931
+#define FDISPLAY_RAW 0x01 /* omit the border */
932
+#define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
933
+#define FDISPLAY_HISTORY 0x04 /* Showing edit history */
934
+#define FDISPLAY_SELECTED 0x08 /* This is the selected post */
935
+#define FDISPLAY_ISROOT 0x10 /* This is the root post */
936
+
930937
/*
931938
** Display a single post in a forum thread.
932939
*/
933940
static void forum_display_post(
934941
ForumThread *pThread, /* The thread that this post is a member of */
935942
ForumPost *p, /* Forum post to display */
936943
int iIndentScale, /* Indent scale factor */
937
- int bRaw, /* True to omit the border */
938
- int bUnf, /* True to leave the post unformatted */
939
- int bHist, /* True if showing edit history */
940
- int bSelect, /* True if this is the selected post */
944
+ int flags, /* From the FDISPLAY_... enum */
941945
char *zQuery /* Common query string */
942946
){
943947
char *zPosterName; /* Name of user who originally made this post */
944948
char *zEditorName; /* Name of user who provided the current edit */
945949
char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
948952
Manifest *pManifest; /* Manifest comprising the current post */
949953
int bPrivate; /* True for posts awaiting moderation */
950954
int bSameUser; /* True if author is also the reader */
951955
int iIndent; /* Indent level */
952956
int iClosed; /* True if (sub)thread is closed */
957
+ const int bRaw = flags & FDISPLAY_RAW;
958
+ const int bUnf = flags & FDISPLAY_UNFORMATTED;
959
+ const int bHist = flags & FDISPLAY_HISTORY;
960
+ const int bSelect = flags & FDISPLAY_SELECTED;
953961
const char *zMimetype;/* Formatting MIME type */
954962
955963
/* Get the manifest for the post. Abort if not found (e.g. shunned). */
956964
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
957965
if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
11201128
const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
11211129
if( forumpost_may_close() && iClosed>=0 ){
11221130
@ <form method="post" \
11231131
@ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
11241132
login_insert_csrf_secret();
1125
- @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1133
+ @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
11261134
if( moderation_pending(p->fpid)==0 ){
11271135
@ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1128
- @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
1136
+ @ class='hidden %s(iClosed ? "action-reopen" : "action-close")'/>
1137
+ /* ^^^ activated by fossil.page.forumpost.js */
11291138
}
11301139
@ </form>
11311140
}
11321141
if( g.perm.Admin ||
11331142
(login_is_individual()
@@ -1140,10 +1149,21 @@
11401149
@ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
11411150
@ <input type="submit" value="Attach...">
11421151
login_insert_csrf_secret();
11431152
moderation_pending_www(p->fpid);
11441153
@ </form>
1154
+ }
1155
+ if( !p->pIrt && g.perm.Setup ){
1156
+ const int isPinned = forum_rid_is_tagged(pHead->fpid, "pinned", 0);
1157
+ @ <form method="post" \
1158
+ @ action='%R/forumpost_%s(isPinned ? "unpin" : "pin")'>
1159
+ login_insert_csrf_secret();
1160
+ @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1161
+ @ <input type="button" value='%s(isPinned ? "Unpin" : "Pin")' \
1162
+ @ class='hidden %s(isPinned ? "action-unpin" : "action-pin")'/>
1163
+ /* ^^^ activated by fossil.page.forumpost.js */
1164
+ @ </form>
11451165
}
11461166
}
11471167
@ </div>
11481168
}
11491169
@ </div>
@@ -1259,12 +1279,19 @@
12591279
}
12601280
12611281
/* Display the appropriate subset of posts in sequence. */
12621282
while( p ){
12631283
/* Display the post. */
1264
- forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1265
- bUnf, bHist, p==pSelect, zQuery);
1284
+ forum_display_post(
1285
+ pThread, p, iIndentScale,
1286
+ (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1287
+ (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1288
+ (bHist ? FDISPLAY_HISTORY : 0) |
1289
+ (p==pSelect ? FDISPLAY_SELECTED : 0) |
1290
+ ((0==fpid || fpid==froot) ? FDISPLAY_ISROOT : 0),
1291
+ zQuery
1292
+ );
12661293
12671294
/* Advance to the next post in the thread. */
12681295
if( mode==FD_CHRONO ){
12691296
/* Chronological mode: display posts (optionally including edits) in their
12701297
** original commit order. */
@@ -1634,10 +1661,29 @@
16341661
mimetype_option_menu(zMimetype, "mimetype");
16351662
@ <div class="forum-editor-widget">
16361663
@ <textarea aria-label="Content:" name="content" class="wikiedit" \
16371664
@ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
16381665
}
1666
+
1667
+/*
1668
+** Internal helper for /forumpost_XYZ internal pages which tag/untag
1669
+** posts.
1670
+*/
1671
+static void forumpost_action_helper(const char *zTag, const char *zVal,
1672
+ int addTag){
1673
+ const char *zFpid = PD("fpid","");
1674
+ int fpid;
1675
+
1676
+ cgi_csrf_verify();
1677
+ fpid = symbolic_name_to_rid(zFpid, "f");
1678
+ if( fpid<=0 ){
1679
+ webpage_error("Missing or invalid fpid query parameter");
1680
+ }
1681
+ forumpost_tag(fpid, zTag, addTag, zVal);
1682
+ cgi_redirectf("%R/forumpost/%S",zFpid);
1683
+ return;
1684
+}
16391685
16401686
/*
16411687
** WEBPAGE: forumpost_close hidden
16421688
** WEBPAGE: forumpost_reopen hidden
16431689
**
@@ -1648,30 +1694,39 @@
16481694
** API for forumpost_tag(). After (perhaps) modifying the "closed"
16491695
** status of the given thread, it redirects to that post's thread
16501696
** view. Requires admin privileges.
16511697
*/
16521698
void forum_page_close(void){
1653
- const char *zFpid = PD("fpid","");
1654
- const char *zReason = 0;
1655
- int fClose;
1656
- int fpid;
1657
-
16581699
login_check_credentials();
16591700
if( forumpost_may_close()==0 ){
16601701
login_needed(g.anon.Admin);
1661
- return;
1662
- }
1663
- cgi_csrf_verify();
1664
- fpid = symbolic_name_to_rid(zFpid, "f");
1665
- if( fpid<=0 ){
1666
- webpage_error("Missing or invalid fpid query parameter");
1667
- }
1668
- fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1669
- if( fClose ) zReason = PD("reason",0);
1670
- forumpost_tag(fpid, "closed", fClose, zReason);
1671
- cgi_redirectf("%R/forumpost/%S",zFpid);
1672
- return;
1702
+ }else{
1703
+ const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1704
+ char const *zReason = bIsAdd ? 0 : PD("reason", 0);
1705
+ forumpost_action_helper("closed", zReason, bIsAdd);
1706
+ }
1707
+}
1708
+
1709
+/*
1710
+** WEBPAGE: forumpost_pin hidden
1711
+** WEBPAGE: forumpost_unpin hidden
1712
+**
1713
+** fpid=X Hash of the post to be edited. REQUIRED
1714
+**
1715
+** Pins or unpins the given forum post, within the bounds of the
1716
+** API for forumpost_tag(). After (perhaps) modifying the "pinned"
1717
+** tag of the given thread, it redirects to that post's thread
1718
+** view. Requires setup privileges.
1719
+*/
1720
+void forum_page_pin(void){
1721
+ login_check_credentials();
1722
+ if( !g.perm.Setup ){
1723
+ login_needed(g.anon.Setup);
1724
+ }else{
1725
+ const int bIsAdd = sqlite3_strglob("*_pin*", g.zPath)==0;
1726
+ forumpost_action_helper("pinned", 0, bIsAdd);
1727
+ }
16731728
}
16741729
16751730
/*
16761731
** WEBPAGE: forumnew
16771732
** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
22412296
style_submenu_entry("n","Max:",4,0);
22422297
iOfst = atoi(PD("x","0"));
22432298
iCnt = 0;
22442299
if( db_table_exists("repository","forumpost") ){
22452300
db_prepare(&q,
2246
- "WITH thread(age,duration,cnt,root,last) AS ("
2301
+ "WITH thread(age,duration,cnt,root,last,sticky) AS ("
22472302
" SELECT"
22482303
" julianday('now') - max(fmtime),"
22492304
" max(fmtime) - min(fmtime),"
22502305
" sum(fprev IS NULL),"
22512306
" froot,"
22522307
" (SELECT fpid FROM forumpost AS y"
22532308
" WHERE y.froot=x.froot %s"
2254
- " ORDER BY y.fmtime DESC LIMIT 1)"
2309
+ " ORDER BY y.fmtime DESC LIMIT 1),"
2310
+ " CASE WHEN"
2311
+ " firt IS NULL AND"
2312
+ " (SELECT 1 FROM tagxref ref, tag t"
2313
+ " WHERE ref.rid=x.fpid AND ref.tagtype>0"
2314
+ " AND ref.tagid=t.tagid"
2315
+ " AND t.tagname='pinned')"
2316
+ " THEN 1"
2317
+ " ELSE 0"
2318
+ " END"
22552319
" FROM forumpost AS x"
22562320
" WHERE %s"
22572321
" GROUP BY froot"
2258
- " ORDER BY 1 LIMIT %d OFFSET %d"
2322
+ " ORDER BY 6 DESC, 1 LIMIT %d OFFSET %d"
22592323
")"
22602324
"SELECT"
22612325
" thread.age," /* 0 */
22622326
" thread.duration," /* 1 */
22632327
" thread.cnt," /* 2 */
22642328
" blob.uuid," /* 3 */
22652329
" substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2266
- " thread.last" /* 5 */
2330
+ " thread.last," /* 5 */
2331
+ " thread.sticky" /* 6 */
22672332
" FROM thread, blob, event"
22682333
" WHERE blob.rid=thread.last"
22692334
" AND event.objid=thread.last"
2270
- " ORDER BY 1;",
2335
+ " ORDER BY 7 DESC, 1;",
22712336
g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
22722337
g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
22732338
iLimit+1, iOfst
22742339
);
22752340
while( db_step(&q)==SQLITE_ROW ){
22762341
char *zAge = human_readable_age(db_column_double(&q,0));
22772342
int nMsg = db_column_int(&q, 2);
2343
+ int bSticky = db_column_int(&q, 6);
22782344
const char *zUuid = db_column_text(&q, 3);
22792345
const char *zTitle = db_column_text(&q, 4);
22802346
if( iCnt==0 ){
22812347
if( iOfst>0 ){
22822348
@ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
23012367
@ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
23022368
@ &darr; Older...</a></td></tr>
23032369
fossil_free(zAge);
23042370
break;
23052371
}
2306
- @ <tr><td>%h(zAge) ago</td>
2372
+ @ <tr%s(bSticky ? " class='sticky'" : "")><td>%h(zAge) ago</td>
23072373
@ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
23082374
@ <td>\
23092375
if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
23102376
@ <span class="modpending">\
23112377
@ Awaiting Moderator Approval</span><br>
23122378
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378 int trid; /* RID of new control artifact */
379 char *zUuid; /* UUID of head version of post */
380
381 db_begin_transaction();
382 frid = forumpost_head_rid(frid);
383 iTagged = forum_rid_is_tagged(frid, "closed", 1);
384 if( (iTagged && addTag
385 /* Already tagged, noting that in the case of (addTag<0) it may
386 ** actually be a parent which is tagged. */)
387 || (iTagged<=0 && !addTag
388 /* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925 static void forum_render_attachment_list2(ForumPost *p){
926 if( p->pEditHead ) p = p->pEditHead;
927 forum_render_attachment_list(p->zUuid);
928 }
929
 
 
 
 
 
 
 
930 /*
931 ** Display a single post in a forum thread.
932 */
933 static void forum_display_post(
934 ForumThread *pThread, /* The thread that this post is a member of */
935 ForumPost *p, /* Forum post to display */
936 int iIndentScale, /* Indent scale factor */
937 int bRaw, /* True to omit the border */
938 int bUnf, /* True to leave the post unformatted */
939 int bHist, /* True if showing edit history */
940 int bSelect, /* True if this is the selected post */
941 char *zQuery /* Common query string */
942 ){
943 char *zPosterName; /* Name of user who originally made this post */
944 char *zEditorName; /* Name of user who provided the current edit */
945 char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
948 Manifest *pManifest; /* Manifest comprising the current post */
949 int bPrivate; /* True for posts awaiting moderation */
950 int bSameUser; /* True if author is also the reader */
951 int iIndent; /* Indent level */
952 int iClosed; /* True if (sub)thread is closed */
 
 
 
 
953 const char *zMimetype;/* Formatting MIME type */
954
955 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
956 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
957 if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
1120 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1121 if( forumpost_may_close() && iClosed>=0 ){
1122 @ <form method="post" \
1123 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1124 login_insert_csrf_secret();
1125 @ <input type="hidden" name="fpid" value="%s(pHead->zUuid)" />
1126 if( moderation_pending(p->fpid)==0 ){
1127 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1128 @ class='%s(iClosed ? "action-reopen" : "action-close")'/>
 
1129 }
1130 @ </form>
1131 }
1132 if( g.perm.Admin ||
1133 (login_is_individual()
@@ -1140,10 +1149,21 @@
1140 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1141 @ <input type="submit" value="Attach...">
1142 login_insert_csrf_secret();
1143 moderation_pending_www(p->fpid);
1144 @ </form>
 
 
 
 
 
 
 
 
 
 
 
1145 }
1146 }
1147 @ </div>
1148 }
1149 @ </div>
@@ -1259,12 +1279,19 @@
1259 }
1260
1261 /* Display the appropriate subset of posts in sequence. */
1262 while( p ){
1263 /* Display the post. */
1264 forum_display_post(pThread, p, iIndentScale, mode==FD_RAW,
1265 bUnf, bHist, p==pSelect, zQuery);
 
 
 
 
 
 
 
1266
1267 /* Advance to the next post in the thread. */
1268 if( mode==FD_CHRONO ){
1269 /* Chronological mode: display posts (optionally including edits) in their
1270 ** original commit order. */
@@ -1634,10 +1661,29 @@
1634 mimetype_option_menu(zMimetype, "mimetype");
1635 @ <div class="forum-editor-widget">
1636 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1637 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1638 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1639
1640 /*
1641 ** WEBPAGE: forumpost_close hidden
1642 ** WEBPAGE: forumpost_reopen hidden
1643 **
@@ -1648,30 +1694,39 @@
1648 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1649 ** status of the given thread, it redirects to that post's thread
1650 ** view. Requires admin privileges.
1651 */
1652 void forum_page_close(void){
1653 const char *zFpid = PD("fpid","");
1654 const char *zReason = 0;
1655 int fClose;
1656 int fpid;
1657
1658 login_check_credentials();
1659 if( forumpost_may_close()==0 ){
1660 login_needed(g.anon.Admin);
1661 return;
1662 }
1663 cgi_csrf_verify();
1664 fpid = symbolic_name_to_rid(zFpid, "f");
1665 if( fpid<=0 ){
1666 webpage_error("Missing or invalid fpid query parameter");
1667 }
1668 fClose = sqlite3_strglob("*_close*", g.zPath)==0;
1669 if( fClose ) zReason = PD("reason",0);
1670 forumpost_tag(fpid, "closed", fClose, zReason);
1671 cgi_redirectf("%R/forumpost/%S",zFpid);
1672 return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1673 }
1674
1675 /*
1676 ** WEBPAGE: forumnew
1677 ** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
2241 style_submenu_entry("n","Max:",4,0);
2242 iOfst = atoi(PD("x","0"));
2243 iCnt = 0;
2244 if( db_table_exists("repository","forumpost") ){
2245 db_prepare(&q,
2246 "WITH thread(age,duration,cnt,root,last) AS ("
2247 " SELECT"
2248 " julianday('now') - max(fmtime),"
2249 " max(fmtime) - min(fmtime),"
2250 " sum(fprev IS NULL),"
2251 " froot,"
2252 " (SELECT fpid FROM forumpost AS y"
2253 " WHERE y.froot=x.froot %s"
2254 " ORDER BY y.fmtime DESC LIMIT 1)"
 
 
 
 
 
 
 
 
 
2255 " FROM forumpost AS x"
2256 " WHERE %s"
2257 " GROUP BY froot"
2258 " ORDER BY 1 LIMIT %d OFFSET %d"
2259 ")"
2260 "SELECT"
2261 " thread.age," /* 0 */
2262 " thread.duration," /* 1 */
2263 " thread.cnt," /* 2 */
2264 " blob.uuid," /* 3 */
2265 " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2266 " thread.last" /* 5 */
 
2267 " FROM thread, blob, event"
2268 " WHERE blob.rid=thread.last"
2269 " AND event.objid=thread.last"
2270 " ORDER BY 1;",
2271 g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
2272 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2273 iLimit+1, iOfst
2274 );
2275 while( db_step(&q)==SQLITE_ROW ){
2276 char *zAge = human_readable_age(db_column_double(&q,0));
2277 int nMsg = db_column_int(&q, 2);
 
2278 const char *zUuid = db_column_text(&q, 3);
2279 const char *zTitle = db_column_text(&q, 4);
2280 if( iCnt==0 ){
2281 if( iOfst>0 ){
2282 @ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
2301 @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2302 @ &darr; Older...</a></td></tr>
2303 fossil_free(zAge);
2304 break;
2305 }
2306 @ <tr><td>%h(zAge) ago</td>
2307 @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2308 @ <td>\
2309 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2310 @ <span class="modpending">\
2311 @ Awaiting Moderator Approval</span><br>
2312
--- src/forum.c
+++ src/forum.c
@@ -378,11 +378,11 @@
378 int trid; /* RID of new control artifact */
379 char *zUuid; /* UUID of head version of post */
380
381 db_begin_transaction();
382 frid = forumpost_head_rid(frid);
383 iTagged = forum_rid_is_tagged(frid, zTagName, 1);
384 if( (iTagged && addTag
385 /* Already tagged, noting that in the case of (addTag<0) it may
386 ** actually be a parent which is tagged. */)
387 || (iTagged<=0 && !addTag
388 /* This entry is not tagged, but a parent post may be. */) ){
@@ -925,21 +925,25 @@
925 static void forum_render_attachment_list2(ForumPost *p){
926 if( p->pEditHead ) p = p->pEditHead;
927 forum_render_attachment_list(p->zUuid);
928 }
929
930 /* Flags for use with forum_display_post() */
931 #define FDISPLAY_RAW 0x01 /* omit the border */
932 #define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */
933 #define FDISPLAY_HISTORY 0x04 /* Showing edit history */
934 #define FDISPLAY_SELECTED 0x08 /* This is the selected post */
935 #define FDISPLAY_ISROOT 0x10 /* This is the root post */
936
937 /*
938 ** Display a single post in a forum thread.
939 */
940 static void forum_display_post(
941 ForumThread *pThread, /* The thread that this post is a member of */
942 ForumPost *p, /* Forum post to display */
943 int iIndentScale, /* Indent scale factor */
944 int flags, /* From the FDISPLAY_... enum */
 
 
 
945 char *zQuery /* Common query string */
946 ){
947 char *zPosterName; /* Name of user who originally made this post */
948 char *zEditorName; /* Name of user who provided the current edit */
949 char *zDate; /* The time/date string */
@@ -948,10 +952,14 @@
952 Manifest *pManifest; /* Manifest comprising the current post */
953 int bPrivate; /* True for posts awaiting moderation */
954 int bSameUser; /* True if author is also the reader */
955 int iIndent; /* Indent level */
956 int iClosed; /* True if (sub)thread is closed */
957 const int bRaw = flags & FDISPLAY_RAW;
958 const int bUnf = flags & FDISPLAY_UNFORMATTED;
959 const int bHist = flags & FDISPLAY_HISTORY;
960 const int bSelect = flags & FDISPLAY_SELECTED;
961 const char *zMimetype;/* Formatting MIME type */
962
963 /* Get the manifest for the post. Abort if not found (e.g. shunned). */
964 pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0);
965 if( !pManifest ) return;
@@ -1120,14 +1128,15 @@
1128 const ForumPost *pHead = p->pEditHead ? p->pEditHead : p;
1129 if( forumpost_may_close() && iClosed>=0 ){
1130 @ <form method="post" \
1131 @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'>
1132 login_insert_csrf_secret();
1133 @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1134 if( moderation_pending(p->fpid)==0 ){
1135 @ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \
1136 @ class='hidden %s(iClosed ? "action-reopen" : "action-close")'/>
1137 /* ^^^ activated by fossil.page.forumpost.js */
1138 }
1139 @ </form>
1140 }
1141 if( g.perm.Admin ||
1142 (login_is_individual()
@@ -1140,10 +1149,21 @@
1149 @ <input type="hidden" name="forumpost" value="%T(pHead->zUuid)">
1150 @ <input type="submit" value="Attach...">
1151 login_insert_csrf_secret();
1152 moderation_pending_www(p->fpid);
1153 @ </form>
1154 }
1155 if( !p->pIrt && g.perm.Setup ){
1156 const int isPinned = forum_rid_is_tagged(pHead->fpid, "pinned", 0);
1157 @ <form method="post" \
1158 @ action='%R/forumpost_%s(isPinned ? "unpin" : "pin")'>
1159 login_insert_csrf_secret();
1160 @ <input type="hidden" name="fpid" value="%s(p->zUuid)" />
1161 @ <input type="button" value='%s(isPinned ? "Unpin" : "Pin")' \
1162 @ class='hidden %s(isPinned ? "action-unpin" : "action-pin")'/>
1163 /* ^^^ activated by fossil.page.forumpost.js */
1164 @ </form>
1165 }
1166 }
1167 @ </div>
1168 }
1169 @ </div>
@@ -1259,12 +1279,19 @@
1279 }
1280
1281 /* Display the appropriate subset of posts in sequence. */
1282 while( p ){
1283 /* Display the post. */
1284 forum_display_post(
1285 pThread, p, iIndentScale,
1286 (mode==FD_RAW ? FDISPLAY_RAW : 0) |
1287 (bUnf ? FDISPLAY_UNFORMATTED : 0) |
1288 (bHist ? FDISPLAY_HISTORY : 0) |
1289 (p==pSelect ? FDISPLAY_SELECTED : 0) |
1290 ((0==fpid || fpid==froot) ? FDISPLAY_ISROOT : 0),
1291 zQuery
1292 );
1293
1294 /* Advance to the next post in the thread. */
1295 if( mode==FD_CHRONO ){
1296 /* Chronological mode: display posts (optionally including edits) in their
1297 ** original commit order. */
@@ -1634,10 +1661,29 @@
1661 mimetype_option_menu(zMimetype, "mimetype");
1662 @ <div class="forum-editor-widget">
1663 @ <textarea aria-label="Content:" name="content" class="wikiedit" \
1664 @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div>
1665 }
1666
1667 /*
1668 ** Internal helper for /forumpost_XYZ internal pages which tag/untag
1669 ** posts.
1670 */
1671 static void forumpost_action_helper(const char *zTag, const char *zVal,
1672 int addTag){
1673 const char *zFpid = PD("fpid","");
1674 int fpid;
1675
1676 cgi_csrf_verify();
1677 fpid = symbolic_name_to_rid(zFpid, "f");
1678 if( fpid<=0 ){
1679 webpage_error("Missing or invalid fpid query parameter");
1680 }
1681 forumpost_tag(fpid, zTag, addTag, zVal);
1682 cgi_redirectf("%R/forumpost/%S",zFpid);
1683 return;
1684 }
1685
1686 /*
1687 ** WEBPAGE: forumpost_close hidden
1688 ** WEBPAGE: forumpost_reopen hidden
1689 **
@@ -1648,30 +1694,39 @@
1694 ** API for forumpost_tag(). After (perhaps) modifying the "closed"
1695 ** status of the given thread, it redirects to that post's thread
1696 ** view. Requires admin privileges.
1697 */
1698 void forum_page_close(void){
 
 
 
 
 
1699 login_check_credentials();
1700 if( forumpost_may_close()==0 ){
1701 login_needed(g.anon.Admin);
1702 }else{
1703 const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0;
1704 char const *zReason = bIsAdd ? 0 : PD("reason", 0);
1705 forumpost_action_helper("closed", zReason, bIsAdd);
1706 }
1707 }
1708
1709 /*
1710 ** WEBPAGE: forumpost_pin hidden
1711 ** WEBPAGE: forumpost_unpin hidden
1712 **
1713 ** fpid=X Hash of the post to be edited. REQUIRED
1714 **
1715 ** Pins or unpins the given forum post, within the bounds of the
1716 ** API for forumpost_tag(). After (perhaps) modifying the "pinned"
1717 ** tag of the given thread, it redirects to that post's thread
1718 ** view. Requires setup privileges.
1719 */
1720 void forum_page_pin(void){
1721 login_check_credentials();
1722 if( !g.perm.Setup ){
1723 login_needed(g.anon.Setup);
1724 }else{
1725 const int bIsAdd = sqlite3_strglob("*_pin*", g.zPath)==0;
1726 forumpost_action_helper("pinned", 0, bIsAdd);
1727 }
1728 }
1729
1730 /*
1731 ** WEBPAGE: forumnew
1732 ** WEBPAGE: forumedit
@@ -2241,42 +2296,53 @@
2296 style_submenu_entry("n","Max:",4,0);
2297 iOfst = atoi(PD("x","0"));
2298 iCnt = 0;
2299 if( db_table_exists("repository","forumpost") ){
2300 db_prepare(&q,
2301 "WITH thread(age,duration,cnt,root,last,sticky) AS ("
2302 " SELECT"
2303 " julianday('now') - max(fmtime),"
2304 " max(fmtime) - min(fmtime),"
2305 " sum(fprev IS NULL),"
2306 " froot,"
2307 " (SELECT fpid FROM forumpost AS y"
2308 " WHERE y.froot=x.froot %s"
2309 " ORDER BY y.fmtime DESC LIMIT 1),"
2310 " CASE WHEN"
2311 " firt IS NULL AND"
2312 " (SELECT 1 FROM tagxref ref, tag t"
2313 " WHERE ref.rid=x.fpid AND ref.tagtype>0"
2314 " AND ref.tagid=t.tagid"
2315 " AND t.tagname='pinned')"
2316 " THEN 1"
2317 " ELSE 0"
2318 " END"
2319 " FROM forumpost AS x"
2320 " WHERE %s"
2321 " GROUP BY froot"
2322 " ORDER BY 6 DESC, 1 LIMIT %d OFFSET %d"
2323 ")"
2324 "SELECT"
2325 " thread.age," /* 0 */
2326 " thread.duration," /* 1 */
2327 " thread.cnt," /* 2 */
2328 " blob.uuid," /* 3 */
2329 " substr(event.comment,instr(event.comment,':')+1)," /* 4 */
2330 " thread.last," /* 5 */
2331 " thread.sticky" /* 6 */
2332 " FROM thread, blob, event"
2333 " WHERE blob.rid=thread.last"
2334 " AND event.objid=thread.last"
2335 " ORDER BY 7 DESC, 1;",
2336 g.perm.ModForum ? "" : "AND y.fpid NOT IN private" /*safe-for-%s*/,
2337 g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/,
2338 iLimit+1, iOfst
2339 );
2340 while( db_step(&q)==SQLITE_ROW ){
2341 char *zAge = human_readable_age(db_column_double(&q,0));
2342 int nMsg = db_column_int(&q, 2);
2343 int bSticky = db_column_int(&q, 6);
2344 const char *zUuid = db_column_text(&q, 3);
2345 const char *zTitle = db_column_text(&q, 4);
2346 if( iCnt==0 ){
2347 if( iOfst>0 ){
2348 @ <h1>Threads at least %s(zAge) old</h1>
@@ -2301,11 +2367,11 @@
2367 @ %z(href("%R/forum?x=%d&n=%d",iOfst+iLimit,iLimit))\
2368 @ &darr; Older...</a></td></tr>
2369 fossil_free(zAge);
2370 break;
2371 }
2372 @ <tr%s(bSticky ? " class='sticky'" : "")><td>%h(zAge) ago</td>
2373 @ <td>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a></td>
2374 @ <td>\
2375 if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){
2376 @ <span class="modpending">\
2377 @ Awaiting Moderator Approval</span><br>
2378
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -100,11 +100,12 @@
100100
101101
if(F.pikchr){
102102
F.pikchr.addSrcView();
103103
}
104104
105
- /* Attempt to keep stray double-clicks from double-posting. */
105
+ /* Attempt to keep stray double-clicks from double-posting.
106
+ https://fossil-scm.org/forum/info/6bd02466533aa131 */
106107
const formSubmitted = function(event){
107108
const form = event.target;
108109
if( form.dataset.submitted ){
109110
event.preventDefault();
110111
return;
@@ -120,16 +121,28 @@
120121
document.querySelectorAll("form").forEach(function(form){
121122
form.addEventListener('submit',formSubmitted);
122123
form
123124
.querySelectorAll("input.action-close, input.action-reopen")
124125
.forEach(function(e){
126
+ e.classList.remove('hidden');
125127
F.confirmer(e, {
126128
confirmText: (e.classList.contains('action-reopen')
127129
? "Confirm re-open"
128130
: "Confirm close"),
129131
onconfirm: ()=>form.submit()
130132
});
131133
});
134
+ form
135
+ .querySelectorAll("input.action-pin, input.action-unpin")
136
+ .forEach(function(e){
137
+ e.classList.remove('hidden');
138
+ F.confirmer(e, {
139
+ confirmText: (e.classList.contains('action-unpin')
140
+ ? "Confirm unpin"
141
+ : "Confirm pin"),
142
+ onconfirm: ()=>form.submit()
143
+ });
144
+ });
132145
});
133146
134147
})/*F.onPageLoad callback*/;
135148
})(window.fossil);
136149
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -100,11 +100,12 @@
100
101 if(F.pikchr){
102 F.pikchr.addSrcView();
103 }
104
105 /* Attempt to keep stray double-clicks from double-posting. */
 
106 const formSubmitted = function(event){
107 const form = event.target;
108 if( form.dataset.submitted ){
109 event.preventDefault();
110 return;
@@ -120,16 +121,28 @@
120 document.querySelectorAll("form").forEach(function(form){
121 form.addEventListener('submit',formSubmitted);
122 form
123 .querySelectorAll("input.action-close, input.action-reopen")
124 .forEach(function(e){
 
125 F.confirmer(e, {
126 confirmText: (e.classList.contains('action-reopen')
127 ? "Confirm re-open"
128 : "Confirm close"),
129 onconfirm: ()=>form.submit()
130 });
131 });
 
 
 
 
 
 
 
 
 
 
 
132 });
133
134 })/*F.onPageLoad callback*/;
135 })(window.fossil);
136
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -100,11 +100,12 @@
100
101 if(F.pikchr){
102 F.pikchr.addSrcView();
103 }
104
105 /* Attempt to keep stray double-clicks from double-posting.
106 https://fossil-scm.org/forum/info/6bd02466533aa131 */
107 const formSubmitted = function(event){
108 const form = event.target;
109 if( form.dataset.submitted ){
110 event.preventDefault();
111 return;
@@ -120,16 +121,28 @@
121 document.querySelectorAll("form").forEach(function(form){
122 form.addEventListener('submit',formSubmitted);
123 form
124 .querySelectorAll("input.action-close, input.action-reopen")
125 .forEach(function(e){
126 e.classList.remove('hidden');
127 F.confirmer(e, {
128 confirmText: (e.classList.contains('action-reopen')
129 ? "Confirm re-open"
130 : "Confirm close"),
131 onconfirm: ()=>form.submit()
132 });
133 });
134 form
135 .querySelectorAll("input.action-pin, input.action-unpin")
136 .forEach(function(e){
137 e.classList.remove('hidden');
138 F.confirmer(e, {
139 confirmText: (e.classList.contains('action-unpin')
140 ? "Confirm unpin"
141 : "Confirm pin"),
142 onconfirm: ()=>form.submit()
143 });
144 });
145 });
146
147 })/*F.onPageLoad callback*/;
148 })(window.fossil);
149
--- www/changes.wiki
+++ www/changes.wiki
@@ -19,10 +19,14 @@
1919
<li> Forum posts may now have attachments if their poster has the new "B"
2020
capability.</li>
2121
<li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
2222
to limit the size of file attachments to wiki pages, tech notes,
2323
tickets, and forum posts.
24
+ <li> Forum threads may now be "pinned", also known as "sticky", such that
25
+ they will sort first in the thread list view. To pin or unpin a post,
26
+ a Setup user must visit the top-most post in the thread and then tap
27
+ the new button.
2428
</ol>
2529
2630
<h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
2731
<li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
2832
<li> The default configuration now allows robots to download any tarball
2933
--- www/changes.wiki
+++ www/changes.wiki
@@ -19,10 +19,14 @@
19 <li> Forum posts may now have attachments if their poster has the new "B"
20 capability.</li>
21 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
22 to limit the size of file attachments to wiki pages, tech notes,
23 tickets, and forum posts.
 
 
 
 
24 </ol>
25
26 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
27 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
28 <li> The default configuration now allows robots to download any tarball
29
--- www/changes.wiki
+++ www/changes.wiki
@@ -19,10 +19,14 @@
19 <li> Forum posts may now have attachments if their poster has the new "B"
20 capability.</li>
21 <li> Add the "[/help/attachment-size-limit|attachment-size-limit]" setting
22 to limit the size of file attachments to wiki pages, tech notes,
23 tickets, and forum posts.
24 <li> Forum threads may now be "pinned", also known as "sticky", such that
25 they will sort first in the thread list view. To pin or unpin a post,
26 a Setup user must visit the top-most post in the thread and then tap
27 the new button.
28 </ol>
29
30 <h2 id='v2_28'>Changes for version 2.28 (2026-03-11)</h2><ol>
31 <li> Improvements to [./antibot.wiki|anti-robot defenses]:<ol type="a">
32 <li> The default configuration now allows robots to download any tarball
33

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button