heddit-0.0.1: src/Network/Reddit/Subreddit.hs
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
-- |
-- Module : Network.Reddit.Types.Subreddit
-- Copyright : (c) 2021 Rory Tyler Hayford
-- License : BSD-3-Clause
-- Maintainer : rory.hayford@protonmail.com
-- Stability : experimental
-- Portability : GHC
--
module Network.Reddit.Subreddit
( -- * Actions
getSubreddit
, getSubredditRules
, getPostRequirements
, getHotSubmissions
, getNewSubmissions
, getRandomRisingSubmissions
, getControversialSubmissions
, getRisingSubmissions
, getTopSubmissions
, getRandomSubmission
, getStickiedSubmission
, subscribe
, unsubscribe
, quarantineOptIn
, quarantineOptOut
-- * Subreddit @Listing@s
-- | These actions return @Listing@s of subreddits site-wide
-- corresponding to various filters
, getDefaultSubreddits
, getNewSubreddits
, getPopularSubreddits
, getPremiumSubreddits
, getGoldSubreddits
, searchSubreddits
, searchSubredditsByName
, getRecommendedSubreddits
, followCollection
, unfollowCollection
-- * Collections
, getCollections
, getCollectionsWithName
, getCollection
, getCollectionByPermalink
-- * Flair
, getUserFlairTemplates
, getSubmissionFlairTemplates
, getNewSubmissionFlairChoices
, getUserFlairChoices
, getSubmissionFlairChoices
-- * Wiki
, getWikiPage
, getWikiPages
, getWikiPageRevision
, getWikiPageRevisions
, editWikiPage
, createWikiPage
-- * Widgets
, getSubredditWidgets
, getAllSubredditWidgets
-- * Emojis
, getSubredditEmojis
-- * Types
, module M
) where
import Control.Monad.Catch ( MonadCatch(catch)
, MonadThrow(throwM)
)
import Data.Aeson ( FromJSON )
import Data.ByteString ( ByteString )
import Data.Generics.Wrapped ( wrappedTo )
import Data.Maybe ( fromMaybe )
import Data.Sequence ( Seq((:<|)) )
import Data.Text ( Text )
import qualified Data.Text.Encoding as T
import Lens.Micro ( (&), (<&>) )
import qualified Network.HTTP.Client.Conduit as H
import Network.Reddit.Internal
import Network.Reddit.Types
import Network.Reddit.Types.Emoji
import Network.Reddit.Types.Emoji as M
( Emoji(Emoji)
, EmojiName
, mkEmoji
, mkEmojiName
)
import Network.Reddit.Types.Flair
import Network.Reddit.Types.Flair as M
( AssignedFlair(AssignedFlair)
, CSSClass
, CurrentUserFlair
, FlairChoice(FlairChoice)
, FlairChoiceList
, FlairContent(..)
, FlairID
, FlairList(FlairList)
, FlairResult(FlairResult)
, FlairSelection(FlairSelection)
, FlairTemplate(FlairTemplate)
, FlairText
, FlairType(..)
, ForegroundColor(..)
, PostedFlairTemplate
, UserFlair(UserFlair)
, defaultFlairTemplate
, flairlistToListing
, mkFlairText
)
import Network.Reddit.Types.Item
import Network.Reddit.Types.Submission
import Network.Reddit.Types.Subreddit
import Network.Reddit.Types.Subreddit as M
( BodyRestriction(..)
, NewSubredditRule(NewSubredditRule)
, PostRequirements(PostRequirements)
, PostedSubredditRule
, RuleType(..)
, Subreddit(Subreddit)
, SubredditID(SubredditID)
, SubredditName
, SubredditRule(SubredditRule)
, mkSubredditName
)
import Network.Reddit.Types.Widget
import Network.Reddit.Types.Widget as M
( Button(..)
, ButtonHover(..)
, ButtonImage(ButtonImage)
, ButtonText(ButtonText)
, ButtonWidget(ButtonWidget)
, CalendarConfig(CalendarConfig)
, CalendarWidget(CalendarWidget)
, CommunityInfo(CommunityInfo)
, CommunityListWidget(CommunityListWidget)
, CustomWidget(CustomWidget)
, IDCardWidget(IDCardWidget)
, Image(Image)
, ImageData(ImageData)
, ImageHover(ImageHover)
, ImageWidget(ImageWidget)
, MenuChild(..)
, MenuLink(MenuLink)
, MenuWidget(MenuWidget)
, ModInfo(ModInfo)
, ModeratorsWidget(ModeratorsWidget)
, PostFlairInfo(PostFlairInfo)
, PostFlairWidget(PostFlairWidget)
, PostFlairWidgetDisplay(..)
, RulesDisplay(..)
, RulesWidget(RulesWidget)
, ShortName
, Submenu(Submenu)
, SubredditWidgets(SubredditWidgets)
, TextAreaWidget(TextAreaWidget)
, TextHover(TextHover)
, Widget(..)
, WidgetID(WidgetID)
, WidgetSection(..)
, WidgetStyles(WidgetStyles)
, defaultCalendarConfig
, mkCommunityInfo
, mkPostFlairWidget
, mkShortName
, mkTextAreaWidget
)
import Network.Reddit.Types.Wiki as M
( WikiPage(WikiPage)
, WikiPageListing
, WikiPageName
, WikiPageSettings(WikiPageSettings)
, WikiPermLevel(..)
, WikiRevision(WikiRevision)
, WikiRevisionID
, mkWikiPageName
)
import Network.Reddit.Utils
import Web.FormUrlEncoded ( Form )
import Web.HttpApiData ( ToHttpApiData(..) )
import Web.Internal.FormUrlEncoded ( ToForm(toForm) )
-- | Get information about a 'Subreddit'. An 'ErrorWithStatus' will be thrown if
-- attempting to query information on banned or private 'Subreddit's
getSubreddit :: MonadReddit m => SubredditName -> m Subreddit
getSubreddit sname = catchEmptyListing
$ runAction defaultAPIAction
{ pathSegments = [ "r", toUrlPiece sname, "about" ] }
-- | Get a 'Subreddit'\'s 'SubredditRule's
getSubredditRules :: MonadReddit m => SubredditName -> m (Seq SubredditRule)
getSubredditRules sname = runAction @RuleList r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = [ "r", toUrlPiece sname, "about", "rules" ] }
-- | Get the requirements that moderators have configured for all submissions on
-- the given subreddit
getPostRequirements :: MonadReddit m => SubredditName -> m PostRequirements
getPostRequirements sname =
runAction defaultAPIAction
{ pathSegments =
[ "api", "v1", toUrlPiece sname, "post_requirements" ]
}
getHotSubmissions, getNewSubmissions, getRandomRisingSubmissions
:: MonadReddit m
=> SubredditName
-> Paginator SubmissionID Submission
-> m (Listing SubmissionID Submission)
-- | Get \"hot\" 'Submission's for a given 'Subreddit'
getHotSubmissions = submissions "hot"
-- | Get \"new\" 'Submission's for a given 'Subreddit'
getNewSubmissions = submissions "new"
getControversialSubmissions, getRisingSubmissions, getTopSubmissions
:: MonadReddit m
=> SubredditName
-> Paginator SubmissionID Submission
-> m (Listing SubmissionID Submission)
-- | Get \"controversial\" 'Submission's for a given 'Subreddit'
getControversialSubmissions = submissions "controversial"
-- | Get \"rising\" 'Submission's for a given 'Subreddit'
getRisingSubmissions = submissions "rising"
-- | Get \"top\" 'Submission's for a given 'Subreddit'
getTopSubmissions = submissions "top"
-- | Get \"rising\" 'Submission's for a given 'Subreddit'
getRandomRisingSubmissions = submissions "randomrising"
submissions :: (MonadReddit m, Thing a, FromJSON a)
=> Text
-> SubredditName
-> Paginator a Submission
-> m (Listing a Submission)
submissions txt sname paginator =
runAction defaultAPIAction
{ pathSegments = [ "r", toUrlPiece sname, txt ]
, requestData = paginatorToFormData paginator
}
-- | Get a random submission from the subreddit. The sub must support this feature,
-- or an 'ErrorWithStatus' exception will be thrown
getRandomSubmission :: MonadReddit m => SubredditName -> m Submission
getRandomSubmission sname =
catchRedirected "getRandomSubmission" action handler
where
action =
runAction defaultAPIAction
{ pathSegments = [ "r", toUrlPiece sname, "random" ]
, followRedirects = False
}
handler ps =
runAction @[Listing ItemID Item]
defaultAPIAction
{ pathSegments = T.decodeUtf8 <$> ps, needsAuth = False }
-- | Get one of the stickied submission, optionally specifying its position in the
-- sticky list, returning the top one otherwise. Note that this will throw an
-- 'ErrorWithStatus' if the sub does not have any stickied submissions
getStickiedSubmission
:: MonadReddit m
=> Maybe Word
-- ^ Which sticky to fetch. 1 is at the top of the sticky list, and is
-- the default if this param is @Nothing@
-> SubredditName
-> m Submission
getStickiedSubmission num sname =
catchRedirected "getStickiedSubmission" action handler
where
action =
runAction defaultAPIAction
{ pathSegments = subAboutPath sname "sticky"
, followRedirects = False
, requestData =
mkTextFormData [ ( "num"
, toQueryParam $ fromMaybe 1 num
)
]
}
handler ps =
runAction @[Listing ItemID Item]
defaultAPIAction
{ pathSegments = T.decodeUtf8 <$> ps, needsAuth = False }
-- HACK
-- This is a mega-hack to deal with how Reddit deals with the absence or
-- presence of certain subreddit features, like random and stickied
-- submissions
catchRedirected :: MonadCatch m
=> Text
-> m Submission
-> ([ByteString] -> m [Listing t Item])
-> m Submission
catchRedirected func action handler = catch @_ @APIException action $ \case
Redirected (Just req) -> case req & H.path & splitPath of
[ r@"r", sub, c@"comments", path, t, j@".json" ] ->
handler [ r, sub, c, path, t, j ] >>= \case
Listing { children } : _ -> handleChildren children
_ -> noResults
_ -> throwM . InvalidResponse
$ func <> ": Could not parse redirect URL"
e -> throwM e
where
noResults = throwM . InvalidResponse $ func <> ": No results"
handleChildren = \case
SubmissionItem s :<| _ -> pure s
_ -> noResults
-- | Subscribe to a single subreddit
subscribe :: MonadReddit m => SubredditName -> m ()
subscribe sname =
runAction_ defaultAPIAction
{ pathSegments = [ "api", "subscribe" ]
, method = POST
, requestData =
mkTextFormData [ ("sr_name", toQueryParam sname)
, ("action", "sub")
, ( "skip_initial_defaults"
, toQueryParam True
)
]
}
-- | Unsubscribe from a single subreddit
unsubscribe :: MonadReddit m => SubredditName -> m ()
unsubscribe sname =
runAction_ defaultAPIAction
{ pathSegments = [ "api", "subscribe" ]
, method = POST
, requestData =
mkTextFormData [ ("sr_name", toQueryParam sname)
, ("action", "unsub")
]
}
-- | Allow the authenticated user to access the quarantined subreddit
quarantineOptIn :: MonadReddit m => SubredditName -> m ()
quarantineOptIn = quarantineOpt "quarantine_opt_in"
-- | Disallow the authenticated user from accessing the quarantined subreddit
quarantineOptOut :: MonadReddit m => SubredditName -> m ()
quarantineOptOut = quarantineOpt "quarantine_opt_out"
quarantineOpt :: MonadReddit m => PathSegment -> SubredditName -> m ()
quarantineOpt path sname =
runAction_ defaultAPIAction
{ pathSegments = [ "api", path ]
, method = POST
, requestData =
mkTextFormData [ ("sr_name", toQueryParam sname) ]
}
getDefaultSubreddits, getNewSubreddits, getPopularSubreddits
:: MonadReddit m
=> Paginator SubredditID Subreddit
-> m (Listing SubredditID Subreddit)
-- | Get a @Listing@ of the default 'Subreddit's
getDefaultSubreddits = subredditListing "default"
-- | Get a @Listing@ of new 'Subreddit's site-wide
getNewSubreddits = subredditListing "new"
-- | Get a @Listing@ of popular 'Subreddit's site-wide
getPopularSubreddits = subredditListing "popular"
getPremiumSubreddits, getGoldSubreddits
:: MonadReddit m
=> Paginator SubredditID Subreddit
-> m (Listing SubredditID Subreddit)
-- | Get a @Listing@ of premium-only 'Subreddit's
getPremiumSubreddits = subredditListing "premium"
-- | Same as 'getPremiumSubreddits', provided for compatibility purposes
getGoldSubreddits = getPremiumSubreddits
-- | Search through subreddits based on both their names and descriptions
searchSubreddits :: MonadReddit m
=> Text
-> Paginator SubredditID Subreddit
-> m (Listing SubredditID Subreddit)
searchSubreddits query paginator =
runAction defaultAPIAction
{ pathSegments = [ "subreddits", "search" ]
, requestData =
WithForm $ toForm paginator <> mkTextForm [ ("q", query) ]
}
-- | Search through subreddits based on both their names
searchSubredditsByName
:: MonadReddit m
=> Maybe Bool
-- ^ If NSWF subreddits should be included, defaulting to @True@ if @Nothing@
-> Maybe Bool
-- ^ Only exactly match the query, defaulting to @False@ if @Nothing@
-> Text
-> m (Seq SubredditName)
searchSubredditsByName withNSFW exact query =
runAction @NameSearchResults r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = [ "api", "search_reddit_names" ]
, method = POST
, requestData =
mkTextFormData [ ("query", query)
, ("exact", toQueryParam $ fromMaybe False exact)
, ( "include_over_18"
, toQueryParam $ fromMaybe True withNSFW
)
]
}
-- | Get a list of recommended subreddits based on the provided subs. Subreddits
-- to exclude from the recommendation may optionally be provided.
--
-- __Note__: Unfortunately, as of this writing, this action appears to only
-- return an empty array for all inputs
getRecommendedSubreddits
:: (MonadReddit m, Foldable t)
=> Maybe (t SubredditName) -- ^ Subreddits to omit from the result
-> t SubredditName -- ^ Subreddits to base the recommendations on
-> m (Seq SubredditName)
getRecommendedSubreddits omit snames = runAction @RecsList r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = [ "api", "recommend", "sr", joinParams snames ]
, requestData =
mkTextFormData [ ("omit", maybe mempty joinParams omit) ]
}
subredditListing :: MonadReddit m
=> PathSegment
-> Paginator SubredditID Subreddit
-> m (Listing SubredditID Subreddit)
subredditListing path paginator =
runAction defaultAPIAction
{ pathSegments = [ "subreddits", path ]
, requestData = paginatorToFormData paginator
}
-- | Get the 'Collection's of a subreddit, given the sub ID. Collections obtained
-- through this action will not have the @sortedLinks@ field
--
-- __Note__: if you don't know the ID of the subreddit, you can use
-- 'getNamedCollections', although this incurs an additional
-- network request to get the ID from the name
getCollections :: MonadReddit m => SubredditID -> m (Seq Collection)
getCollections sid =
runAction defaultAPIAction
{ pathSegments = collectionsPath "subreddit_collections"
, requestData =
mkTextFormData [ ("sr_fullname", fullname sid) ]
}
-- | Get the 'Collection's of a subreddit, given the name of the sub. Collections
-- obtained through this action will not have the @sortedLinks@ field
--
-- __Note__: this incurs a greater overhead than 'getCollections',
-- which you may want to use if you already know the @subredditID@
getCollectionsWithName :: MonadReddit m => SubredditName -> m (Seq Collection)
getCollectionsWithName sname = do
Subreddit { subredditID } <- getSubreddit sname
getCollections subredditID
-- | Fetch the specifig 'Collection', given its ID. This includes its @sortedLinks@
getCollection :: MonadReddit m => CollectionID -> m Collection
getCollection cid = catch @_ @APIException action $ \case
-- An invalid UUID will cause Reddit to return an empty JSON array
ErrorWithMessage EmptyError -> throwM . ErrorWithStatus
$ StatusMessage 404 "getCollection: Collection does not exist"
e -> throwM e
where
action =
runAction defaultAPIAction
{ pathSegments = collectionsPath "collection"
, requestData = mkTextFormData [ ("collection_id", cid)
, ("include_links", "true")
]
}
-- | Get a 'Collection' given its @permalink@. This includes its @sortedLinks@
--
-- Permalink URLs should be of the form
-- https:\/\/{www.}reddit.com\/r/\<SUBREDDIT\>\/collections\/\<ID\>
getCollectionByPermalink :: MonadReddit m => URL -> m Collection
getCollectionByPermalink pl = splitURL pl >>= \case
(_, [ "r", _, "collection", cid ]) -> getCollection cid
_ -> throwM
$ InvalidRequest "getCollectionByPermalink: invalid permalink provided"
-- | Follow the collection for the authenticated user
followCollection :: MonadReddit m => CollectionID -> m ()
followCollection = followUnfollow True
-- | Unfollow the collection for the authenticated user
unfollowCollection :: MonadReddit m => CollectionID -> m ()
unfollowCollection = followUnfollow False
followUnfollow :: MonadReddit m => Bool -> CollectionID -> m ()
followUnfollow follow cid =
runAction_ defaultAPIAction
{ pathSegments = collectionsPath "follow_collection"
, method = POST
, requestData =
mkTextFormData [ ("collection_id", cid)
, ("follow", toQueryParam follow)
]
}
collectionsPath :: PathSegment -> [PathSegment]
collectionsPath path = [ "api", "v1", "collections", path ]
-- | Get the user 'FlairTemplate's on the given subreddit. This will throw an
-- 'APIException' ('ErrorWithStatus') if the sub does not allow users to set
-- their own flair and the authenticated user does not have mod privileges on
-- the sub
getUserFlairTemplates
:: MonadReddit m => SubredditName -> m (Seq FlairTemplate)
getUserFlairTemplates = v2Flair "user"
-- | Get the submission 'FlairTemplate's on the given subreddit
getSubmissionFlairTemplates
:: MonadReddit m => SubredditName -> m (Seq FlairTemplate)
getSubmissionFlairTemplates = v2Flair "link"
v2Flair :: MonadReddit m => Text -> SubredditName -> m (Seq FlairTemplate)
v2Flair path sname =
runAction defaultAPIAction
{ pathSegments = subAPIPath sname $ path <> "_flair_v2" }
-- | Get the available 'FlairChoice's for new submissions on the given subreddit
getNewSubmissionFlairChoices
:: MonadReddit m => SubredditName -> m (Seq FlairChoice)
getNewSubmissionFlairChoices =
flairChoices (mkTextForm [ ("is_newlink", toQueryParam True) ])
-- | Get the available 'FlairChoice's for a particular submission on the given
-- subreddit
getSubmissionFlairChoices
:: MonadReddit m => SubredditName -> SubmissionID -> m (Seq FlairChoice)
getSubmissionFlairChoices sname sid = flairChoices form sname
where
form = mkTextForm [ ("link", fullname sid) ]
-- | Get the available 'FlairChoice's for new submissions on the current subreddit
getUserFlairChoices :: MonadReddit m => SubredditName -> m (Seq FlairChoice)
getUserFlairChoices = flairChoices (mkTextForm mempty)
flairChoices :: MonadReddit m => Form -> SubredditName -> m (Seq FlairChoice)
flairChoices form sname = runAction @FlairChoiceList r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = subAPIPath sname "flairselector"
, method = POST
, requestData = WithForm form
}
-- | Get the subreddit 'WikiPage' specified by name
getWikiPage :: MonadReddit m => SubredditName -> WikiPageName -> m WikiPage
getWikiPage sname wpage =
runAction defaultAPIAction
{ pathSegments =
[ "r", toUrlPiece sname, "wiki", toUrlPiece wpage ]
}
-- | Get all of the 'WikiPage's on the subreddit wiki
getWikiPages :: MonadReddit m => SubredditName -> m (Seq WikiPageName)
getWikiPages sname = runAction @WikiPageListing r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = [ "r", toUrlPiece sname, "wiki", "pages" ] }
-- | Get a specific revision of a 'WikiPage', specified by name and 'WikiRevisionID'
getWikiPageRevision :: MonadReddit m
=> SubredditName
-> WikiPageName
-> WikiRevisionID
-> m WikiPage
getWikiPageRevision sname wpage wr =
runAction defaultAPIAction
{ pathSegments =
[ "r", toUrlPiece sname, "wiki", toUrlPiece wpage ]
, requestData = mkTextFormData [ ("v", toQueryParam wr) ]
}
-- | Get a 'Listing' of the 'WikiRevision's for a given wikipage
getWikiPageRevisions
:: MonadReddit m
=> SubredditName
-> WikiPageName
-> Paginator WikiRevisionID WikiRevision
-> m (Listing WikiRevisionID WikiRevision)
getWikiPageRevisions sname wpage paginator =
runAction defaultAPIAction
{ pathSegments = [ "r"
, toUrlPiece sname
, "wiki"
, "revisions"
, toUrlPiece wpage
]
, requestData = paginatorToFormData paginator
}
-- | Edit the given wikipage, replacing its contents with the new contents provided.
-- This requires moderator privileges or editing privileges for the page in question.
-- If the page corresponding to the given name does not exist, it will be created
editWikiPage :: MonadReddit m
=> SubredditName
-> WikiPageName
-> Maybe Text -- ^ The reason for the edit, if any
-> Body -- ^ The new content for the page
-> m ()
editWikiPage sname wpage r txt =
runAction_ defaultAPIAction
{ pathSegments =
[ "r", toUrlPiece sname, "api", "wiki", "edit" ]
, method = POST
, requestData = mkTextFormData
$ [ ("page", toQueryParam wpage), ("content", txt) ]
<> foldMap pure (("reason", ) <$> r)
}
-- | Create a new wikipage. If a page with the given name already exists, its
-- contents will be replaced
createWikiPage :: MonadReddit m
=> SubredditName
-> WikiPageName
-> Maybe Text -- ^ The reason for creating the page, if any
-> Body -- ^ The new content for the page
-> m ()
createWikiPage = editWikiPage
-- | Get a given subreddit\'s widgets
getSubredditWidgets :: MonadReddit m => SubredditName -> m SubredditWidgets
getSubredditWidgets sname = catchEmptyListing
$ runAction defaultAPIAction { pathSegments = subAPIPath sname "widgets" }
-- | Get all of a subreddit\'s 'Widget's as a non-hierarchical list
getAllSubredditWidgets :: MonadReddit m => SubredditName -> m (Seq Widget)
getAllSubredditWidgets sname =
catchEmptyListing $ runAction @WidgetList r <&> wrappedTo
where
r = defaultAPIAction { pathSegments = subAPIPath sname "widgets" }
-- | Get all of the emojis for the given subreddit. Note that this does not include
-- the builtin \"snoomojis\"
getSubredditEmojis :: MonadReddit m => SubredditName -> m (Seq Emoji)
getSubredditEmojis sname = runAction @EmojiList r <&> wrappedTo
where
r = defaultAPIAction
{ pathSegments = [ "api", "v1", toUrlPiece sname, "emojis", "all" ] }