Implement basic notifications in DB and UI
This commit is contained in:
parent
6d55b8c5d7
commit
c531f41565
7 changed files with 174 additions and 8 deletions
|
@ -45,6 +45,7 @@ OutboxItem
|
||||||
published UTCTime
|
published UTCTime
|
||||||
|
|
||||||
InboxItem
|
InboxItem
|
||||||
|
unread Bool
|
||||||
|
|
||||||
InboxItemLocal
|
InboxItemLocal
|
||||||
person PersonId
|
person PersonId
|
||||||
|
|
|
@ -51,6 +51,7 @@
|
||||||
/s SharersR GET
|
/s SharersR GET
|
||||||
/s/#ShrIdent SharerR GET
|
/s/#ShrIdent SharerR GET
|
||||||
/s/#ShrIdent/inbox SharerInboxR GET POST
|
/s/#ShrIdent/inbox SharerInboxR GET POST
|
||||||
|
/s/#ShrIdent/notifications NotificationsR GET POST
|
||||||
/s/#ShrIdent/outbox OutboxR GET POST
|
/s/#ShrIdent/outbox OutboxR GET POST
|
||||||
/s/#ShrIdent/outbox/#OutboxItemKeyHashid OutboxItemR GET
|
/s/#ShrIdent/outbox/#OutboxItemKeyHashid OutboxItemR GET
|
||||||
|
|
||||||
|
|
|
@ -593,7 +593,7 @@ handleSharerInbox _now shrRecip (Left pidAuthor) _raw activity = do
|
||||||
if pidRecip == pidAuthor
|
if pidRecip == pidAuthor
|
||||||
then return "Received activity authored by self, ignoring"
|
then return "Received activity authored by self, ignoring"
|
||||||
else lift $ do
|
else lift $ do
|
||||||
ibid <- insert InboxItem
|
ibid <- insert $ InboxItem True
|
||||||
miblid <- insertUnique $ InboxItemLocal pidRecip obid ibid
|
miblid <- insertUnique $ InboxItemLocal pidRecip obid ibid
|
||||||
let recip = shr2text shrRecip
|
let recip = shr2text shrRecip
|
||||||
case miblid of
|
case miblid of
|
||||||
|
@ -676,7 +676,7 @@ handleSharerInbox now shrRecip (Right iidSender) raw activity =
|
||||||
jsonObj = PersistJSON raw
|
jsonObj = PersistJSON raw
|
||||||
ract = RemoteActivity iidSender luActivity jsonObj now
|
ract = RemoteActivity iidSender luActivity jsonObj now
|
||||||
ractid <- either entityKey id <$> insertBy' ract
|
ractid <- either entityKey id <$> insertBy' ract
|
||||||
ibid <- insert InboxItem
|
ibid <- insert $ InboxItem True
|
||||||
mibrid <- insertUnique $ InboxItemRemote pidRecip ractid ibid
|
mibrid <- insertUnique $ InboxItemRemote pidRecip ractid ibid
|
||||||
let recip = shr2text shrRecip
|
let recip = shr2text shrRecip
|
||||||
case mibrid of
|
case mibrid of
|
||||||
|
@ -885,7 +885,7 @@ handleProjectInbox now shrRecip prjRecip iidSender hSender raidSender body raw a
|
||||||
-- TODO inefficient, see the other TODOs about mergeConcat
|
-- TODO inefficient, see the other TODOs about mergeConcat
|
||||||
remotes = map (second $ NE.nubBy ((==) `on` fst4)) $ mergeConcat teamRemotes fsRemotes
|
remotes = map (second $ NE.nubBy ((==) `on` fst4)) $ mergeConcat teamRemotes fsRemotes
|
||||||
for_ pids $ \ pid -> do
|
for_ pids $ \ pid -> do
|
||||||
ibid <- insert InboxItem
|
ibid <- insert $ InboxItem True
|
||||||
mibrid <- insertUnique $ InboxItemRemote pid ractid ibid
|
mibrid <- insertUnique $ InboxItemRemote pid ractid ibid
|
||||||
when (isNothing mibrid) $
|
when (isNothing mibrid) $
|
||||||
delete ibid
|
delete ibid
|
||||||
|
@ -1433,7 +1433,7 @@ handleOutboxNote host (Note mluNote luAttrib aud muParent muContext mpublished c
|
||||||
, map (second $ NE.nubBy ((==) `on` fst4)) $ mergeConcat teamRemotes fsRemotes
|
, map (second $ NE.nubBy ((==) `on` fst4)) $ mergeConcat teamRemotes fsRemotes
|
||||||
)
|
)
|
||||||
lift $ for_ (union recipPids morePids) $ \ pid -> do
|
lift $ for_ (union recipPids morePids) $ \ pid -> do
|
||||||
ibid <- insert InboxItem
|
ibid <- insert $ InboxItem True
|
||||||
insert_ $ InboxItemLocal pid obid ibid
|
insert_ $ InboxItemLocal pid obid ibid
|
||||||
return remotes
|
return remotes
|
||||||
where
|
where
|
||||||
|
|
|
@ -55,6 +55,7 @@ import Yesod.Default.Util (addStaticContentExternal)
|
||||||
import qualified Data.ByteString.Char8 as BC (unpack)
|
import qualified Data.ByteString.Char8 as BC (unpack)
|
||||||
import qualified Data.ByteString.Lazy as BL (ByteString)
|
import qualified Data.ByteString.Lazy as BL (ByteString)
|
||||||
import qualified Data.HashMap.Strict as M (lookup, insert)
|
import qualified Data.HashMap.Strict as M (lookup, insert)
|
||||||
|
import qualified Database.Esqueleto as E
|
||||||
import qualified Yesod.Core.Unsafe as Unsafe
|
import qualified Yesod.Core.Unsafe as Unsafe
|
||||||
--import qualified Data.CaseInsensitive as CI
|
--import qualified Data.CaseInsensitive as CI
|
||||||
import qualified Data.Text as T
|
import qualified Data.Text as T
|
||||||
|
@ -211,7 +212,12 @@ instance Yesod App where
|
||||||
defaultLayout widget = do
|
defaultLayout widget = do
|
||||||
master <- getYesod
|
master <- getYesod
|
||||||
mmsg <- getMessage
|
mmsg <- getMessage
|
||||||
mperson <- maybeAuthAllowUnverified
|
mperson <- do
|
||||||
|
mperson' <- maybeAuthAllowUnverified
|
||||||
|
for mperson' $ \ (p@(Entity pid person), verified) -> runDB $ do
|
||||||
|
sharer <- getJust $ personIdent person
|
||||||
|
[E.Value unread] <- countUnread pid
|
||||||
|
return (p, verified, sharer, unread :: Int)
|
||||||
(title, bcs) <- breadcrumbs
|
(title, bcs) <- breadcrumbs
|
||||||
|
|
||||||
-- We break up the default layout into two components:
|
-- We break up the default layout into two components:
|
||||||
|
@ -233,6 +239,22 @@ instance Yesod App where
|
||||||
federatedServers = appInstances settings
|
federatedServers = appInstances settings
|
||||||
$(widgetFile "default-layout")
|
$(widgetFile "default-layout")
|
||||||
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
|
withUrlRenderer $(hamletFile "templates/default-layout-wrapper.hamlet")
|
||||||
|
where
|
||||||
|
countUnread pid =
|
||||||
|
E.select $ E.from $ \ (ib `E.LeftOuterJoin` ibl `E.LeftOuterJoin` ibr) -> do
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibr E.?. InboxItemRemoteItem
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibl E.?. InboxItemLocalItem
|
||||||
|
E.where_ $
|
||||||
|
( E.isNothing (ibr E.?. InboxItemRemotePerson) E.||.
|
||||||
|
ibr E.?. InboxItemRemotePerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
( E.isNothing (ibl E.?. InboxItemLocalPerson) E.||.
|
||||||
|
ibl E.?. InboxItemLocalPerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
ib E.^. InboxItemUnread E.==. E.val True
|
||||||
|
return $ E.count $ ib E.^. InboxItemId
|
||||||
|
|
||||||
-- The page to be redirected to when authentication is required.
|
-- The page to be redirected to when authentication is required.
|
||||||
authRoute _ = Just $ AuthR LoginR
|
authRoute _ = Just $ AuthR LoginR
|
||||||
|
@ -244,6 +266,7 @@ instance Yesod App where
|
||||||
(AuthR (PluginR "account" ["verify", u, _]), False) -> personUnver u
|
(AuthR (PluginR "account" ["verify", u, _]), False) -> personUnver u
|
||||||
|
|
||||||
(SharerInboxR shr , False) -> person shr
|
(SharerInboxR shr , False) -> person shr
|
||||||
|
(NotificationsR shr , _ ) -> person shr
|
||||||
(OutboxR shr , True) -> person shr
|
(OutboxR shr , True) -> person shr
|
||||||
|
|
||||||
(GroupsR , True) -> personAny
|
(GroupsR , True) -> personAny
|
||||||
|
@ -692,6 +715,9 @@ instance YesodBreadcrumbs App where
|
||||||
SharersR -> ("Sharers", Just HomeR)
|
SharersR -> ("Sharers", Just HomeR)
|
||||||
SharerR shar -> (shr2text shar, Just SharersR)
|
SharerR shar -> (shr2text shar, Just SharersR)
|
||||||
SharerInboxR shr -> ("Inbox", Just $ SharerR shr)
|
SharerInboxR shr -> ("Inbox", Just $ SharerR shr)
|
||||||
|
NotificationsR shr -> ( "Notifications"
|
||||||
|
, Just $ SharerR shr
|
||||||
|
)
|
||||||
|
|
||||||
PeopleR -> ("People", Just HomeR)
|
PeopleR -> ("People", Just HomeR)
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,8 @@ module Vervis.Handler.Inbox
|
||||||
, postOutboxR
|
, postOutboxR
|
||||||
, getActorKey1R
|
, getActorKey1R
|
||||||
, getActorKey2R
|
, getActorKey2R
|
||||||
|
, getNotificationsR
|
||||||
|
, postNotificationsR
|
||||||
)
|
)
|
||||||
where
|
where
|
||||||
|
|
||||||
|
@ -32,7 +34,7 @@ import Prelude
|
||||||
|
|
||||||
import Control.Applicative ((<|>))
|
import Control.Applicative ((<|>))
|
||||||
import Control.Concurrent.STM.TVar (readTVarIO, modifyTVar')
|
import Control.Concurrent.STM.TVar (readTVarIO, modifyTVar')
|
||||||
import Control.Exception (displayException)
|
import Control.Exception hiding (Handler)
|
||||||
import Control.Monad
|
import Control.Monad
|
||||||
import Control.Monad.IO.Class (liftIO)
|
import Control.Monad.IO.Class (liftIO)
|
||||||
import Control.Monad.Logger.CallStack
|
import Control.Monad.Logger.CallStack
|
||||||
|
@ -56,6 +58,7 @@ import Data.Text.Lazy.Encoding (decodeUtf8)
|
||||||
import Data.Time.Clock
|
import Data.Time.Clock
|
||||||
import Data.Time.Interval (TimeInterval, toTimeUnit)
|
import Data.Time.Interval (TimeInterval, toTimeUnit)
|
||||||
import Data.Time.Units (Second)
|
import Data.Time.Units (Second)
|
||||||
|
import Data.Traversable
|
||||||
import Database.Persist
|
import Database.Persist
|
||||||
import Network.HTTP.Client (Manager, HttpException, requestFromURI)
|
import Network.HTTP.Client (Manager, HttpException, requestFromURI)
|
||||||
import Network.HTTP.Simple (httpJSONEither, getResponseBody, setRequestManager, addRequestHeader)
|
import Network.HTTP.Simple (httpJSONEither, getResponseBody, setRequestManager, addRequestHeader)
|
||||||
|
@ -68,7 +71,7 @@ import Yesod.Auth (requireAuth)
|
||||||
import Yesod.Core
|
import Yesod.Core
|
||||||
import Yesod.Core.Json (requireJsonBody)
|
import Yesod.Core.Json (requireJsonBody)
|
||||||
import Yesod.Core.Handler
|
import Yesod.Core.Handler
|
||||||
import Yesod.Form.Fields (Textarea (..), textField, textareaField)
|
import Yesod.Form.Fields
|
||||||
import Yesod.Form.Functions
|
import Yesod.Form.Functions
|
||||||
import Yesod.Form.Types
|
import Yesod.Form.Types
|
||||||
import Yesod.Persist.Core
|
import Yesod.Persist.Core
|
||||||
|
@ -98,6 +101,7 @@ import Yesod.Hashids
|
||||||
import qualified Data.Aeson.Encode.Pretty.ToEncoding as AEP
|
import qualified Data.Aeson.Encode.Pretty.ToEncoding as AEP
|
||||||
|
|
||||||
import Data.Aeson.Local
|
import Data.Aeson.Local
|
||||||
|
import Data.Either.Local
|
||||||
import Data.EventTime.Local
|
import Data.EventTime.Local
|
||||||
import Data.Paginate.Local
|
import Data.Paginate.Local
|
||||||
import Data.Time.Clock.Local
|
import Data.Time.Clock.Local
|
||||||
|
@ -488,3 +492,132 @@ getActorKey1R = getActorKey (\ (k1, _, _) -> k1) ActorKey1R
|
||||||
|
|
||||||
getActorKey2R :: Handler TypedContent
|
getActorKey2R :: Handler TypedContent
|
||||||
getActorKey2R = getActorKey (\ (_, k2, _) -> k2) ActorKey2R
|
getActorKey2R = getActorKey (\ (_, k2, _) -> k2) ActorKey2R
|
||||||
|
|
||||||
|
notificationForm :: Maybe (Maybe (InboxItemId, Bool)) -> Form (Maybe (InboxItemId, Bool))
|
||||||
|
notificationForm defs = renderDivs $ mk
|
||||||
|
<$> aopt hiddenField "Inbox Item ID#" (fmap fst <$> defs)
|
||||||
|
<*> aopt hiddenField "New unread flag" (fmap snd <$> defs)
|
||||||
|
where
|
||||||
|
mk Nothing Nothing = Nothing
|
||||||
|
mk (Just ibid) (Just unread) = Just (ibid, unread)
|
||||||
|
mk _ _ = error "Missing hidden field?"
|
||||||
|
|
||||||
|
getNotificationsR :: ShrIdent -> Handler Html
|
||||||
|
getNotificationsR shr = do
|
||||||
|
items <- runDB $ do
|
||||||
|
sid <- getKeyBy404 $ UniqueSharer shr
|
||||||
|
pid <- getKeyBy404 $ UniquePersonIdent sid
|
||||||
|
map adaptItem <$> getItems pid
|
||||||
|
notifications <- for items $ \ (ibid, activity) -> do
|
||||||
|
((_result, widget), enctype) <-
|
||||||
|
runFormPost $ notificationForm $ Just $ Just (ibid, False)
|
||||||
|
return (activity, widget, enctype)
|
||||||
|
((_result, widgetAll), enctypeAll) <-
|
||||||
|
runFormPost $ notificationForm $ Just Nothing
|
||||||
|
defaultLayout $(widgetFile "person/notifications")
|
||||||
|
where
|
||||||
|
getItems pid =
|
||||||
|
E.select $ E.from $
|
||||||
|
\ (ib `E.LeftOuterJoin` (ibl `E.InnerJoin` ob) `E.LeftOuterJoin` (ibr `E.InnerJoin` ract)) -> do
|
||||||
|
E.on $ ibr E.?. InboxItemRemoteActivity E.==. ract E.?. RemoteActivityId
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibr E.?. InboxItemRemoteItem
|
||||||
|
E.on $ ibl E.?. InboxItemLocalActivity E.==. ob E.?. OutboxItemId
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibl E.?. InboxItemLocalItem
|
||||||
|
E.where_
|
||||||
|
$ ( E.isNothing (ibr E.?. InboxItemRemotePerson) E.||.
|
||||||
|
ibr E.?. InboxItemRemotePerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
( E.isNothing (ibl E.?. InboxItemLocalPerson) E.||.
|
||||||
|
ibl E.?. InboxItemLocalPerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
ib E.^. InboxItemUnread E.==. E.val True
|
||||||
|
E.orderBy [E.desc $ ib E.^. InboxItemId]
|
||||||
|
return
|
||||||
|
( ib E.^. InboxItemId
|
||||||
|
, ob E.?. OutboxItemActivity
|
||||||
|
, ract E.?. RemoteActivityContent
|
||||||
|
)
|
||||||
|
adaptItem (E.Value ibid, E.Value mact, E.Value mobj) =
|
||||||
|
case (mact, mobj) of
|
||||||
|
(Nothing, Nothing) ->
|
||||||
|
error $
|
||||||
|
"InboxItem #" ++ show ibid ++ " neither local nor remote"
|
||||||
|
(Just _, Just _) ->
|
||||||
|
error $ "InboxItem #" ++ show ibid ++ " both local and remote"
|
||||||
|
(Just act, Nothing) -> (ibid, Left $ persistJSONValue act)
|
||||||
|
(Nothing, Just obj) -> (ibid, Right $ persistJSONValue obj)
|
||||||
|
|
||||||
|
postNotificationsR :: ShrIdent -> Handler Html
|
||||||
|
postNotificationsR shr = do
|
||||||
|
((result, _widget), _enctype) <- runFormPost $ notificationForm Nothing
|
||||||
|
case result of
|
||||||
|
FormSuccess mitem -> do
|
||||||
|
(multi, markedUnread) <- runDB $ do
|
||||||
|
sid <- getKeyBy404 $ UniqueSharer shr
|
||||||
|
pid <- getKeyBy404 $ UniquePersonIdent sid
|
||||||
|
case mitem of
|
||||||
|
Nothing -> do
|
||||||
|
ibids <- map E.unValue <$> getItems pid
|
||||||
|
updateWhere
|
||||||
|
[InboxItemId <-. ibids]
|
||||||
|
[InboxItemUnread =. False]
|
||||||
|
return (True, False)
|
||||||
|
Just (ibid, unread) -> do
|
||||||
|
mibl <- getValBy $ UniqueInboxItemLocalItem ibid
|
||||||
|
mibr <- getValBy $ UniqueInboxItemRemoteItem ibid
|
||||||
|
mib <-
|
||||||
|
requireEitherM
|
||||||
|
mibl
|
||||||
|
mibr
|
||||||
|
"Unused InboxItem"
|
||||||
|
"InboxItem used more than once"
|
||||||
|
let samePid =
|
||||||
|
case mib of
|
||||||
|
Left ibl ->
|
||||||
|
inboxItemLocalPerson ibl == pid
|
||||||
|
Right ibr ->
|
||||||
|
inboxItemRemotePerson ibr == pid
|
||||||
|
if samePid
|
||||||
|
then do
|
||||||
|
update ibid [InboxItemUnread =. unread]
|
||||||
|
return (False, unread)
|
||||||
|
else
|
||||||
|
permissionDenied
|
||||||
|
"Notification belongs to different user"
|
||||||
|
setMessage $
|
||||||
|
if multi
|
||||||
|
then "Items marked as read."
|
||||||
|
else if markedUnread
|
||||||
|
then "Item marked as unread."
|
||||||
|
else "Item marked as read."
|
||||||
|
FormMissing -> do
|
||||||
|
setMessage "Field(s) missing"
|
||||||
|
FormFailure l -> do
|
||||||
|
setMessage $ toHtml $ "Marking as read failed:" <> T.pack (show l)
|
||||||
|
redirect $ NotificationsR shr
|
||||||
|
where
|
||||||
|
getItems pid =
|
||||||
|
E.select $ E.from $
|
||||||
|
\ (ib `E.LeftOuterJoin` ibl `E.LeftOuterJoin` ibr) -> do
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibr E.?. InboxItemRemoteItem
|
||||||
|
E.on $ E.just (ib E.^. InboxItemId) E.==. ibl E.?. InboxItemLocalItem
|
||||||
|
E.where_
|
||||||
|
$ ( E.isNothing (ibr E.?. InboxItemRemotePerson) E.||.
|
||||||
|
ibr E.?. InboxItemRemotePerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
( E.isNothing (ibl E.?. InboxItemLocalPerson) E.||.
|
||||||
|
ibl E.?. InboxItemLocalPerson E.==. E.just (E.val pid)
|
||||||
|
)
|
||||||
|
E.&&.
|
||||||
|
ib E.^. InboxItemUnread E.==. E.val True
|
||||||
|
return $ ib E.^. InboxItemId
|
||||||
|
-- TODO copied from Vervis.Federation, put this in 1 place
|
||||||
|
requireEitherM
|
||||||
|
:: MonadIO m => Maybe a -> Maybe b -> String -> String -> m (Either a b)
|
||||||
|
requireEitherM mx my f t =
|
||||||
|
case requireEither mx my of
|
||||||
|
Left b -> liftIO $ throwIO $ userError $ if b then t else f
|
||||||
|
Right exy -> return exy
|
||||||
|
|
|
@ -278,6 +278,8 @@ changes =
|
||||||
, addEntities model_2019_05_17
|
, addEntities model_2019_05_17
|
||||||
-- 75
|
-- 75
|
||||||
, addFieldPrimOptional "RemoteActor" (Nothing :: Maybe Text) "name"
|
, addFieldPrimOptional "RemoteActor" (Nothing :: Maybe Text) "name"
|
||||||
|
-- 76
|
||||||
|
, addFieldPrimRequired "InboxItem" False "unread"
|
||||||
]
|
]
|
||||||
|
|
||||||
migrateDB :: MonadIO m => ReaderT SqlBackend m (Either Text (Int, Int))
|
migrateDB :: MonadIO m => ReaderT SqlBackend m (Either Text (Int, Int))
|
||||||
|
|
|
@ -12,7 +12,7 @@ $# You should have received a copy of the CC0 Public Domain Dedication along
|
||||||
$# with this software. If not, see
|
$# with this software. If not, see
|
||||||
$# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
$# <http://creativecommons.org/publicdomain/zero/1.0/>.
|
||||||
|
|
||||||
$maybe (Entity _pid person, verified) <- mperson
|
$maybe (Entity _pid person, verified, sharer, unread) <- mperson
|
||||||
<div>
|
<div>
|
||||||
$if verified
|
$if verified
|
||||||
You are logged in as #{personLogin person}.
|
You are logged in as #{personLogin person}.
|
||||||
|
@ -22,6 +22,9 @@ $maybe (Entity _pid person, verified) <- mperson
|
||||||
received a verification link by email, you can ask to
|
received a verification link by email, you can ask to
|
||||||
<a href=@{ResendVerifyEmailR}>resend
|
<a href=@{ResendVerifyEmailR}>resend
|
||||||
it. Or <a href=@{AuthR LogoutR}>Log out.
|
it. Or <a href=@{AuthR LogoutR}>Log out.
|
||||||
|
$if unread > 0
|
||||||
|
<a href=@{NotificationsR $ sharerIdent sharer}>
|
||||||
|
🔔${unread}
|
||||||
$nothing
|
$nothing
|
||||||
<div>
|
<div>
|
||||||
You are not logged in.
|
You are not logged in.
|
||||||
|
|
Loading…
Reference in a new issue