Skip to content

Commit

Permalink
More things for 2024 (jocosocial#254)
Browse files Browse the repository at this point in the history
* remove duplicate trunk lfg, fix announcement button

* resolves jocosocial#253, a workaround for websocket crash

* fix reference to forum fields

* resolves jocosocial#174 add pinned forums and pinned forum posts

for jocosocial#174 add support for pinned posts

make forum pins an api endpoint rather than a query

help text

* remove twarrt locusts, performance configs

* fix migration from blank database

* achieves minimum viability for jocosocial#218

* resolves jocosocial#213, persists hidePast param in pagination

* add isPinned to ForumListData

* fix an lfg->fez socket issue

* account create now goes through coc, page titles

* remove unique call for pinned threads

* implement the new thread sorting

* karaoke commentary

* edit

* make forum pinned non optional default false

* add appropriate filters to pinned posts

* add mutewords
  • Loading branch information
cohoe authored Feb 13, 2024
1 parent 8e15a0e commit 435d448
Show file tree
Hide file tree
Showing 36 changed files with 642 additions and 282 deletions.
138 changes: 134 additions & 4 deletions Sources/swiftarr/Controllers/ForumController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ struct ForumController: APIRouteCollection {
tokenCacheAuthGroup.post(forumIDParam, "mute", "remove", use: muteRemoveHandler)
tokenCacheAuthGroup.delete(forumIDParam, "mute", use: muteRemoveHandler)

// Pins
tokenCacheAuthGroup.post(forumIDParam, "pin", use: forumPinAddHandler)
tokenCacheAuthGroup.post(forumIDParam, "pin", "remove", use: forumPinRemoveHandler)
tokenCacheAuthGroup.delete(forumIDParam, "pin", use: forumPinRemoveHandler)
tokenCacheAuthGroup.get(forumIDParam, "pinnedposts", use: forumPinnedPostsHandler)

tokenCacheAuthGroup.get("search", use: forumSearchHandler)
tokenCacheAuthGroup.get("owner", use: ownerHandler)
tokenCacheAuthGroup.get("recent", use: recentsHandler)
Expand Down Expand Up @@ -82,6 +88,11 @@ struct ForumController: APIRouteCollection {
tokenCacheAuthGroup.delete("post", postIDParam, "love", use: postUnreactHandler)
tokenCacheAuthGroup.post("post", postIDParam, "report", use: postReportHandler)

// Pins
tokenCacheAuthGroup.post("post", postIDParam, "pin", use: forumPostPinAddHandler)
tokenCacheAuthGroup.post("post", postIDParam, "pin", "remove", use: forumPostPinRemoveHandler)
tokenCacheAuthGroup.delete("post", postIDParam, "pin", use: forumPostPinRemoveHandler)

// 'Favorite' applies to forums, while 'Bookmark' is for posts
tokenCacheAuthGroup.post("post", postIDParam, "bookmark", use: bookmarkAddHandler)
tokenCacheAuthGroup.post("post", postIDParam, "bookmark", "remove", use: bookmarkRemoveHandler)
Expand Down Expand Up @@ -183,7 +194,12 @@ struct ForumController: APIRouteCollection {
#"AND "\#(ForumReaders.schema)"."\#(ForumReaders().$user.$id.key)" = '\#(cacheUser.userID)'"#
)
)
// User muting of a forum should take sort precedence over pinning.
// They explicitly don't want to see it, so don't shove it in their face.
// Not relevany anymore, but for anyone reading this in the future:
// Nullibility influences sort order.
.sort(ForumReaders.self, \.$isMuted, .descending)
.sort(Forum.self, \.$pinned, .descending)
if category.isEventCategory {
_ = query.join(child: \.$scheduleEvent, method: .left)
// https://github.com/jocosocial/swiftarr/issues/199
Expand Down Expand Up @@ -426,11 +442,9 @@ struct ForumController: APIRouteCollection {
// binary operator '&&' cannot be applied to operands of type 'ComplexJoinFilter' and 'ModelValueFilter<ForumReaders>'
// which is very sad. The resultant SQL should read something like:
// ... LEFT JOIN "forum+readers" ON "forum"."id"="forum+readers"."forum" AND "forum+readers"."user"='$' WHERE ...
// If there's a sneaky way to access the FieldKey's of models, I haven't been able to find it.
// So if we ever schema change the "forum" or "user" columns here this won't dynamically adjust.
let joinFilters: [DatabaseQuery.Filter] = [
.field(.path([.id], schema: Forum.schema), .equal, .path([.string("forum")], schema: ForumReaders.schema)),
.value(.path(["user"], schema: ForumReaders.schema), .equal, .bind(cacheUser.userID))
.field(.path(Forum.path(for: \.$id), schema: Forum.schema), .equal, .path(ForumReaders.path(for: \.$forum.$id), schema: ForumReaders.schema)),
.value(.path(ForumReaders.path(for: \.$user.$id), schema: ForumReaders.schema), .equal, .bind(cacheUser.userID))
]
let countQuery = Forum.query(on: req.db).filter(\.$creator.$id !~ cacheUser.getBlocks())
.categoryAccessFilter(for: cacheUser)
Expand Down Expand Up @@ -927,6 +941,50 @@ struct ForumController: APIRouteCollection {
return .noContent
}

/// `POST /api/v3/forum/ID/pin`
///
/// Pin the forum to the category.
///
/// - Parameter forumID: In the URL path.
/// - Returns: 201 Created on success; 200 OK if already pinned.
func forumPinAddHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
guard cacheUser.accessLevel.hasAccess(.moderator) else {
throw Abort(.forbidden, reason: "Only moderators can pin a forum thread.")
}
let forum = try await Forum.findFromParameter(forumIDParam, on: req) { query in
query.categoryAccessFilter(for: cacheUser)
}
if forum.pinned == true {
return .ok
}
forum.pinned = true;
try await forum.save(on: req.db)
return .created
}

/// `DELETE /api/v3/forum/ID/pin`
///
/// Unpin the forum from the category.
///
/// - Parameter forumID: In the URL path.
/// - Returns: 204 No Content on success; 200 OK if already not pinned.
func forumPinRemoveHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
guard cacheUser.accessLevel.hasAccess(.moderator) else {
throw Abort(.forbidden, reason: "Only moderators can pin a forum thread.")
}
let forum = try await Forum.findFromParameter(forumIDParam, on: req) { query in
query.categoryAccessFilter(for: cacheUser)
}
if forum.pinned != true {
return .ok
}
forum.pinned = false;
try await forum.save(on: req.db)
return .noContent
}

/// `POST /api/v3/forum/categories/ID/create`
///
/// Creates a new `Forum` in the specified `Category`, and the first `ForumPost` within
Expand Down Expand Up @@ -1308,6 +1366,78 @@ struct ForumController: APIRouteCollection {
let postDataArray = try await buildPostData([post], userID: cacheUser.userID, on: req)
return postDataArray[0]
}

/// `GET /api/v3/forum/:forumID/pinnedposts`
///
/// Get a list of all of the pinned posts within this forum.
/// This currently does not implement paginator because if pagination is needed for pinned
/// posts what the frak have you done?
///
/// - Parameter forumID: In the URL path.
/// - Returns array of `PostData`.
func forumPinnedPostsHandler(_ req: Request) async throws -> [PostData] {
let user = try req.auth.require(UserCacheData.self)
let forum = try await Forum.findFromParameter(forumIDParam, on: req) { query in
query.with(\.$category)
}
try guardUserCanAccessCategory(user, category: forum.category)
let query = try await ForumPost.query(on: req.db)
.filter(\.$author.$id !~ user.getBlocks())
.filter(\.$author.$id !~ user.getMutes())
.categoryAccessFilter(for: user)
.filter(\.$forum.$id == forum.requireID())
.filter(\.$pinned == true)
.all()
return try await buildPostData(query, userID: user.userID, on: req, mutewords: user.mutewords)
}

/// `POST /api/v3/forum/post/:postID/pin`
///
/// Pin the post to the forum.
///
/// - Parameter postID: In the URL path.
/// - Returns: 201 Created on success; 200 OK if already pinned.
func forumPostPinAddHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let post = try await ForumPost.findFromParameter(postIDParam, on: req) { query in
query.with(\.$forum) { forum in
forum.with(\.$category)
}
}
// Only forum creator and moderators can pin posts within a forum.
try cacheUser.guardCanModifyContent(post, customErrorString: "User cannot pin posts in this forum.")

if post.pinned == true {
return .ok
}
post.pinned = true;
try await post.save(on: req.db)
return .created
}

/// `DELETE /api/v3/forum/:postID/ID/pin`
///
/// Unpin the post from the forum.
///
/// - Parameter postID: In the URL path.
/// - Returns: 204 No Content on success; 200 OK if already not pinned.
func forumPostPinRemoveHandler(_ req: Request) async throws -> HTTPStatus {
let cacheUser = try req.auth.require(UserCacheData.self)
let post = try await ForumPost.findFromParameter(postIDParam, on: req) { query in
query.with(\.$forum) { forum in
forum.with(\.$category)
}
}
// Only forum creator and moderators can pin posts within a forum.
try cacheUser.guardCanModifyContent(post, customErrorString: "User cannot pin posts in this forum.")

if post.pinned != true {
return .ok
}
post.pinned = false;
try await post.save(on: req.db)
return .noContent
}
}

// Utilities for route methods
Expand Down
44 changes: 28 additions & 16 deletions Sources/swiftarr/Controllers/PhonecallController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -268,18 +268,25 @@ struct PhonecallController: APIRouteCollection {
callerSocket: ws,
notifySockets: calleeNotificationSockets.map { $0.socket }
)
ws.onClose.whenComplete { result in
endPhoneCall(callID: callID)
}
ws.onBinary { ws, binary in
Task {
if let call = await ActivePhoneCalls.shared.getCall(withID: callID),
let calleeSocket = call.calleeSocket
{
try? await calleeSocket.send([UInt8](buffer: binary))

// https://github.com/jocosocial/swiftarr/issues/253
// https://github.com/vapor/websocket-kit/issues/139
// https://github.com/vapor/websocket-kit/issues/140
ws.eventLoop.execute {
ws.onClose.whenComplete { result in
endPhoneCall(callID: callID)
}
ws.onBinary { ws, binary in
Task {
if let call = await ActivePhoneCalls.shared.getCall(withID: callID),
let calleeSocket = call.calleeSocket
{
try? await calleeSocket.send([UInt8](buffer: binary))
}
}
}
}

}

/// `GET /api/v3/phone/socket/answer/:call_id`
Expand Down Expand Up @@ -338,14 +345,19 @@ struct PhonecallController: APIRouteCollection {
try? await call.calleeSocket?.send(raw: jsonData, opcode: .binary)
}

ws.onClose.whenComplete { result in
endPhoneCall(callID: callID)
}
// https://github.com/jocosocial/swiftarr/issues/253
// https://github.com/vapor/websocket-kit/issues/139
// https://github.com/vapor/websocket-kit/issues/140
ws.eventLoop.execute {
ws.onClose.whenComplete { result in
endPhoneCall(callID: callID)
}

ws.onBinary { ws, binary in
Task {
if let call = await ActivePhoneCalls.shared.getCall(withID: callID) {
try? await call.callerSocket?.send([UInt8](buffer: binary))
ws.onBinary { ws, binary in
Task {
if let call = await ActivePhoneCalls.shared.getCall(withID: callID) {
try? await call.callerSocket?.send([UInt8](buffer: binary))
}
}
}
}
Expand Down
12 changes: 11 additions & 1 deletion Sources/swiftarr/Controllers/Structs/ControllerStructs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ public struct CategoryData: Content {
var isEventCategory: Bool
/// The number of threads in this category
var numThreads: Int32
///The threads in the category. Only populated for /categories/ID.
/// The threads in the category. Only populated for /categories/ID.
var forumThreads: [ForumListData]?
}

Expand Down Expand Up @@ -621,6 +621,8 @@ public struct ForumData: Content {
var posts: [PostData]
/// If this forum is for an Event on the schedule, the ID of the event.
var eventID: UUID?
/// If this forum is pinned or not.
var isPinned: Bool?
}

extension ForumData {
Expand Down Expand Up @@ -648,6 +650,7 @@ extension ForumData {
if let event = event, event.id != nil {
self.eventID = event.id
}
self.isPinned = forum.pinned
}
}

Expand Down Expand Up @@ -693,6 +696,8 @@ public struct ForumListData: Content {
var timeZoneID: String?
/// If this forum is for an Event on the schedule, the ID of the event.
var eventID: UUID?
/// If this forum is pinned or not.
var isPinned: Bool?
}

extension ForumListData {
Expand Down Expand Up @@ -721,6 +726,8 @@ extension ForumListData {
self.isLocked = forum.moderationStatus == .locked
self.isFavorite = isFavorite
self.isMuted = isMuted
self.isPinned = forum.pinned

if let event = event, event.id != nil {
let timeZoneChanges = Settings.shared.timeZoneChanges
self.eventTime = timeZoneChanges.portTimeToDisplayTime(event.startTime)
Expand Down Expand Up @@ -1036,6 +1043,8 @@ public struct PostData: Content {
var userLike: LikeType?
/// The total number of `LikeType` reactions on the post.
var likeCount: Int
/// Whether the post has been pinned to the forum.
var isPinned: Bool?
}

extension PostData {
Expand All @@ -1055,6 +1064,7 @@ extension PostData {
isBookmarked = bookmarked
self.userLike = userLike
self.likeCount = likeCount
self.isPinned = post.pinned
}

// For newly created posts
Expand Down
1 change: 1 addition & 0 deletions Sources/swiftarr/Migrations/Data Import/KaraokeSongs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ struct ImportKaraokeSongs: AsyncMigration {
database.logger.info("Starting karaoke song import")
// get the songs file. Tab-delimited, each line contains: "ARTIST \t SONG_TITLE \t TAGS \n"
// Tags can be: "VR" for voice-reduced, M for midi (I think?)
// File should be UTF-8 encoded with Windows or Linux-style endings (\r\n or \n) and no funky charactes.
let songsFilename: String
do {
if try Environment.detect().isRelease {
Expand Down
53 changes: 43 additions & 10 deletions Sources/swiftarr/Models/Forum.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ final class Forum: Model, Searchable {
/// Timestamp of the model's soft-deletion, set automatically.
@Timestamp(key: "deleted_at", on: .delete) var deletedAt: Date?

/// Is the forum pinned within the category.
@Field(key: "pinned") var pinned: Bool

// MARK: Relations

/// The parent `Category` of the forum.
Expand Down Expand Up @@ -77,6 +80,7 @@ final class Forum: Model, Searchable {
self.lastPostTime = Date()
self.lastPostID = 0
self.moderationStatus = .normal
self.pinned = false
}
}

Expand All @@ -101,26 +105,55 @@ struct CreateForumSchema: AsyncMigration {
}
}

/// This migration used to include populating the last_post_id for all existing rows.
/// Except that acting on Forum before all future migrations have executed (as listed
/// in configure.swift) fails because all of the other fields don't exist yet.
/// So that functionality has been moved to PopulateForumLastPostIDMigration below
/// and that migration runs later on after all schema modifications have been populated.
struct UpdateForumLastPostIDMigration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("forum")
.field("last_post_id", .int)
.update()

// Update all existing forums with the last post.
let forums = try await Forum.query(on: database).all()
for forum in forums {
let forumPostQuery = forum.$posts.query(on: database).sort(\.$createdAt, .descending)
if let lastPost = try await forumPostQuery.first() {
forum.lastPostID = lastPost.id
try await forum.save(on: database)
}
}
}

func revert(on database: Database) async throws {
try await database.schema("forum")
.deleteField("last_post_id")
.update()
}
}

struct UpdateForumPinnedMigration: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("forum")
.field("pinned", .bool, .required, .sql(.default(false)))
.update()
}

func revert(on database: Database) async throws {
try await database.schema("forum")
.deleteField("pinned")
.update()
}
}

struct PopulateForumLastPostIDMigration: AsyncMigration {
func prepare(on database: Database) async throws {
// Update all existing forums with the last post.
let forums = try await Forum.query(on: database).all()
try await database.transaction { transaction in
for forum in forums {
let forumPostQuery = forum.$posts.query(on: transaction).sort(\.$createdAt, .descending)
if let lastPost = try await forumPostQuery.first() {
forum.lastPostID = lastPost.id
try await forum.save(on: transaction)
}
}
}
}

func revert(on database: Database) async throws {
app.logger.log(level: .info, "No revert for this migration.")
}
}
Loading

0 comments on commit 435d448

Please sign in to comment.