Implement basic notifications in DB and UI

This commit is contained in:
fr33domlover 2019-05-22 21:50:30 +00:00
parent 6d55b8c5d7
commit c531f41565
7 changed files with 174 additions and 8 deletions

View file

@ -45,6 +45,7 @@ OutboxItem
published UTCTime published UTCTime
InboxItem InboxItem
unread Bool
InboxItemLocal InboxItemLocal
person PersonId person PersonId

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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))

View file

@ -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.