Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

More things for 2024 #254

Merged
merged 19 commits into from
Feb 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)

cohoe marked this conversation as resolved.
Show resolved Hide resolved
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] {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a bunch of stuff that buildForumData does that this method doesn't, besides pagination. It appears that this fn doesn't respect blocks, mutes, or mutewords, and because it returns an array of PostData instead of a ForumData, this call is difficult to use by itself without also calling GET /api/v3/forum/ID. Not that big of a deal by itself, but if you're getting to a forum via GET /api/v3/forum/post/ID/forum (the call that is used to get from a search results post to the forum containing that post), you'd have to call the post-to-forum call first, and then use that to get the forum ID you need to get the pinned posts in that forum.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call regarding the blocks/mutes/words and buildForumData. I'll add that.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok i've added blocks/mutes/muteword support.

Unless I'm totally high: buildForumData doesn't apply here. That function as currently designed starts from a Forum and does its own query to build posts from there. IMO adding a pinsOnly = true mode adds excessive complexity to an already complex function. Going through the major features:

  • blocks/mutes/mutewords can already be done in the request handler query and matches patterns done elsewher.e
  • start/limit doesn't really apply since this usage really shouldn't support pagination. If someone has paginated the number of pinned posts in a thread, jezus lord we're off the rails. I'd rather institute a "no more than 5 pins per thread" sort of limit on the pin create handler at that point.
  • lastPostID and event data is irrelevant for this use.

As for the call being difficult to use by itself, I'd say "sort-of".

In the web UI we display pinned posts in the normal "thread view" and in the "thread from post view". In the former case, the forumID is in the URL so that's an easy async GET to both /forum/:forum_ID /forum/:forum_ID/pinnedposts. In the latter case, it's a [not-A]sync GET since we have to wait for the ForumData response in the /forum/post/:postID/forum call to then do the /forum/:forum_ID/pinnedposts call. IMO this minor sub-optimization is acceptable.

Tricordarr doesn't even go this far. There is a dedicated "pinned posts" button in the header of the /forum/:forum_ID and /forum/post/:post_ID/forum screens that takes you to the /forum/:forum_ID/pinnedposts screen. Either one doesn't execute until the user does something anyway.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Waxing poetically here for a moment: I'm not even sure the pinned posts at the top of the screen should remain in the "long term" in the Web UI. It could rapidly get overwhelmed and should likely end up in a sub view. At which point perhaps buildForumData returns a pinnedPostCount that can give the user a more useful hint that there's pinned content elsewhere. And a button will take them there. But either way it still requires a dedicated API call that IMO should not take in any more info than it needs to.

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
cohoe marked this conversation as resolved.
Show resolved Hide resolved
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))
}
cohoe marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

}

/// `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