Skip to content

Commit

Permalink
add support for referenced posts in UI
Browse files Browse the repository at this point in the history
- Store reposted events in LMDB
- Add referencedPosts property to show reposts and references
- Extract nostr: URLs from post content
- return post objects instead of IDs for root/parent properties
  • Loading branch information
prolic committed Jan 29, 2025
1 parent ed11925 commit 1a91fd4
Show file tree
Hide file tree
Showing 6 changed files with 142 additions and 110 deletions.
1 change: 1 addition & 0 deletions futr.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ executable futr
pipes >=4.3.16 && <4.4,
random >=1.2.1.2 && <1.3,
random-shuffle >=0.0.4 && <0.1,
regex-tdfa >=1.3.2.2 && <1.4,
scientific >=0.3.8.0 && <0.4,
stm >=2.5.1.0 && <2.6,
string-conversions >=0.4.0.1 && <0.5,
Expand Down
91 changes: 47 additions & 44 deletions resources/qml/content/Components/PostContent.ui.qml
Original file line number Diff line number Diff line change
Expand Up @@ -34,58 +34,61 @@ Pane {
color: Material.foreground
}

// Referenced content box
Rectangle {
visible: post.postType === "repost" || post.postType === "quote_repost"
Layout.fillWidth: true
color: Qt.rgba(0, 0, 0, 0.1)
radius: 8
border.width: 1
border.color: Material.dividerColor

implicitHeight: contentColumn.implicitHeight + 2 * Constants.spacing_m

ColumnLayout {
id: contentColumn
anchors {
fill: parent
margins: Constants.spacing_m
}
spacing: Constants.spacing_s

// Author info row
RowLayout {
Layout.fillWidth: true
spacing: Constants.spacing_m

ProfilePicture {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
imageSource: Util.getProfilePicture(post.referencedAuthorPicture, post.referencedAuthorPubkey)
// Referenced content boxes
Repeater {
model: post.referencedPosts || []
delegate: Rectangle {
visible: true
Layout.fillWidth: true
color: Qt.rgba(0, 0, 0, 0.1)
radius: 8
border.width: 1
border.color: Material.dividerColor

implicitHeight: contentColumn.implicitHeight + 2 * Constants.spacing_m

ColumnLayout {
id: contentColumn
anchors {
fill: parent
margins: Constants.spacing_m
}
spacing: Constants.spacing_s

Text {
// Author info row
RowLayout {
Layout.fillWidth: true
text: post.referencedAuthorName || post.referencedAuthorPubkey || ""
font: Constants.fontMedium
color: Material.foreground
elide: Text.ElideRight
spacing: Constants.spacing_m

ProfilePicture {
Layout.preferredWidth: 36
Layout.preferredHeight: 36
imageSource: modelData.author ? Util.getProfilePicture(modelData.author.picture, modelData.author.npub) : ""
}

Text {
Layout.fillWidth: true
text: modelData.author ? (modelData.author.displayName || modelData.author.name) : ""
font: Constants.fontMedium
color: Material.foreground
elide: Text.ElideRight
}

Text {
text: modelData.timestamp || ""
font: Constants.smallFontMedium
color: Material.secondaryTextColor
}
}

// Referenced content
Text {
text: post.referencedCreatedAt || ""
font: Constants.smallFontMedium
color: Material.secondaryTextColor
Layout.fillWidth: true
text: (modelData.content || "").replace(/nostr:(note|nevent|naddr)1[a-zA-Z0-9]+/g, '').trim()
wrapMode: Text.Wrap
color: Material.foreground
}
}

// Referenced content
Text {
Layout.fillWidth: true
text: (post.referencedContent || "").replace(/nostr:(note|nevent|naddr)1[a-zA-Z0-9]+/g, '').trim()
wrapMode: Text.Wrap
color: Material.foreground
}
}
}

Expand Down
1 change: 0 additions & 1 deletion resources/qml/content/MainContent.ui.qml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import QtQuick 2.15
import QtQuick.Controls 2.15
import QtQuick.Controls.Material 2.15
import QtQuick.Controls.Material.impl 2.15
import QtQuick.Layouts 1.15

import Components 1.0
Expand Down
13 changes: 13 additions & 0 deletions src/Nostr/Bech32.hs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ module Nostr.Bech32
, nprofileToPubKeyXO
, nrelayToRelay
, relayToNrelay
, bech32ToEventId
) where

import Codec.Binary.Bech32 qualified as Bech32
Expand Down Expand Up @@ -170,3 +171,15 @@ decodeTLV bs = runGet go (LBS.fromStrict bs)
v <- getByteString (fromIntegral l)
rest <- go
return $ (t, BSS.toShort v) : rest


-- | Bech32 decoding to EventId
bech32ToEventId :: T.Text -> Maybe EventId
bech32ToEventId txt = do
case T.take 4 txt of
"note" -> fromBech32 "note" txt >>= (Just . EventId)
"nevent" -> do
bs <- fromBech32 "nevent" txt
let tlvs = decodeTLV bs
lookup 0 tlvs >>= (Just . EventId . BSS.fromShort)
_ -> Nothing
3 changes: 2 additions & 1 deletion src/Store/Lmdb.hs
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ runLmdbStore = interpret $ \env -> \case
let etags = [t | t@(ETag _ _ _) <- tags (event ev)]
let mOriginalEvent = eitherDecode (fromStrict $ encodeUtf8 $ content $ event ev)
case (etags, mOriginalEvent) of
(ETag _ _ _:_, Right originalEvent) | validateEvent originalEvent ->
(ETag _ _ _:_, Right originalEvent) | validateEvent originalEvent -> do
Map.repsert' txn eventDb (eventId originalEvent) (EventWithRelays originalEvent Set.empty)
addTimelineEntryTx txn postTimelineDb ev [pubKey $ event ev] (createdAt $ event ev)
_ -> pure ()

Expand Down
143 changes: 79 additions & 64 deletions src/UI.hs
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@

module UI where

import Control.Monad (guard)
import Data.Aeson (decode, encode)
import Control.Monad.Fix (mfix)
import Data.Aeson (decode, eitherDecode, encode)
import Data.ByteString.Lazy qualified as BSL
import Data.List (find)
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.List (find, nub)
import Data.Maybe (fromMaybe, mapMaybe)
import Data.Proxy (Proxy(..))
import Data.Text (pack, unpack)
import Data.Text (Text, drop, pack, unpack)
import Data.Text.Encoding qualified as TE
import Effectful
import Effectful.Dispatch.Dynamic (interpret)
import Effectful.State.Static.Shared (get, modify)
import Effectful.TH
import QtQuick
import Graphics.QML hiding (fireSignal, runEngineLoop)
import Prelude hiding (drop)
import Text.Read (readMaybe)
import Text.Regex.TDFA

import Logging
import Nostr
Expand Down Expand Up @@ -134,108 +135,115 @@ runUI = interpret $ \_ -> \case

followPool <- newFactoryPool (newObject followClass)

let getReferencedEventId event =
case find (\case QTag _ _ _ -> True; _ -> False) (tags event) of
Just (QTag eid _ _) -> return $ Just eid
_ -> return Nothing

getRootReference event =
case find (\case ETag _ _ (Just Root) -> True; _ -> False) (tags event) of
let getRootReference evt =
case find (\case ETag _ _ (Just Root) -> True; _ -> False) (tags evt) of
Just (ETag eid _ _) -> return $ Just eid
_ -> return Nothing

getParentReference event =
case find (\case ETag _ _ (Just Reply) -> True; _ -> False) (tags event) of
getParentReference evt =
case find (\case ETag _ _ (Just Reply) -> True; _ -> False) (tags evt) of
Just (ETag eid _ _) -> return $ Just eid
_ -> return Nothing

postClass <- newClass [
postClass <- mfix $ \postClass' -> newClass [
defPropertySigRO' "id" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
return $ pack $ show eid,
let value = pack $ show eid
return value,

defPropertySigRO' "postType" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent eid
case eventMaybe of
Just eventWithRelays -> return $ Just $ pack $ case kind (event eventWithRelays) of
ShortTextNote ->
if any (\case QTag _ _ _ -> True; _ -> False) (tags (event eventWithRelays))
then "quote_repost"
else "text_note"
Repost -> "repost"
Comment -> "comment"
GiftWrap -> "gift_wrap"
DirectMessage -> "direct_message"
_ -> "unknown"
Nothing -> return Nothing,
let value = case eventMaybe of
Just eventWithRelays ->
pack $ case kind (event eventWithRelays) of
ShortTextNote ->
if any (\t -> case t of
QTag _ _ _ -> True
_ -> False) (tags (event eventWithRelays))
then "quote_repost"
else "short_text_note"
Repost -> "repost"
Comment -> "comment"
GiftWrap -> "gift_wrap"
DirectMessage -> "direct_message"
_ -> "unknown"
Nothing -> "unknown"
return value,

defPropertySigRO' "content" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
runE $ getEvent eid >>= \case
Just eventWithRelays ->
case kind (event eventWithRelays) of
value <- runE $ getEvent eid >>= \case
Just eventWithRelays -> do
let ev = event eventWithRelays
case kind ev of
Repost -> do
case eitherDecode (BSL.fromStrict $ TE.encodeUtf8 $ content ev) of
Right repostedEvent -> return $ Just $ content repostedEvent
Left _ -> return $ Just $ content ev
GiftWrap -> do
kp <- getKeyPair
sealed <- unwrapGiftWrap (event eventWithRelays) kp
sealed <- unwrapGiftWrap ev kp
rumor <- maybe (return Nothing) (unwrapSeal `flip` kp) sealed
return $ rumorContent <$> rumor
_ -> return $ Just $ content (event eventWithRelays)
Nothing -> return Nothing,
_ -> return $ Just $ content ev
Nothing -> return Nothing
return value,

defPropertySigRO' "timestamp" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent eid
case eventMaybe of
value <- case eventMaybe of
Just eventWithRelays -> do
now <- runE getCurrentTime
return $ Just $ formatDateTime English now (createdAt (event eventWithRelays))
Nothing -> return Nothing,

defPropertySigRO' "author" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent eid
case eventMaybe of
Just eventWithRelays -> Just <$> newObject profileClass ()
Nothing -> return Nothing,
Nothing -> return Nothing
return value,

-- For reposts and quote reposts: points to the reposted/quoted event
defPropertySigRO' "referencedId" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent eid
case eventMaybe of
Just eventWithRelays -> getReferencedEventId (event eventWithRelays) >>= \case
Just refId -> return $ Just $ pack $ show refId
Nothing -> return Nothing
Nothing -> return Nothing,
defPropertySigRO' "author" changeKey' $ \_ -> do
Just <$> newObject profileClass (),

-- For comments: points to the original post that started the thread
-- Example: Post A <- Comment B <- Comment C
-- Comment C's rootPost is Post A
defPropertySigRO' "rootId" changeKey' $ \obj -> do
defPropertySigRO' "root" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent eid
case eventMaybe of
Just eventWithRelays -> getRootReference (event eventWithRelays) >>= \case
Just refId -> return $ Just $ pack $ show refId
Just refId -> Just <$> newObject postClass' refId
Nothing -> return Nothing
Nothing -> return Nothing,

-- For nested comments: points to the immediate parent comment when different from root
-- Example: Post A <- Comment B <- Comment C
-- Comment C's parentPost is Comment B
-- Comment B's parentPost is null (same as root)
defPropertySigRO' "parentId" changeKey' $ \obj -> do
defPropertySigRO' "parent" changeKey' $ \obj -> do
let eid = fromObjRef obj :: EventId
runE $ getEvent eid >>= maybe (return Nothing) \eventWithRelays -> do
parentId <- getParentReference (event eventWithRelays)
rootId <- getRootReference (event eventWithRelays)
return $ do -- This is Maybe monad
p <- parentId
r <- rootId
guard (p /= r)
Just $ pack $ show p
eventMaybe <- runE $ getEvent eid
case eventMaybe of
Just eventWithRelays -> do
parentId <- runE $ getParentReference (event eventWithRelays)
rootId <- runE $ getRootReference (event eventWithRelays)
case (parentId, rootId) of
(Just p, Just r) | p /= r -> Just <$> newObject postClass' p
_ -> return Nothing
Nothing -> return Nothing,

-- Referenced posts property
defPropertySigRO' "referencedPosts" changeKey' $ \obj -> do
let postId = fromObjRef obj :: EventId
eventMaybe <- runE $ getEvent postId
case eventMaybe of
Just eventWithRelays -> do
let ev = event eventWithRelays
eTagRefs = [tagId | ETag tagId _ _ <- tags ev]
qTagRefs = [tagId | QTag tagId _ _ <- tags ev]
contentRefs = extractNostrReferences (content ev)
allRefs = nub $ eTagRefs ++ qTagRefs ++ contentRefs
mapM (newObject postClass') allRefs
Nothing -> return []
]

postsPool <- newFactoryPool (newObject postClass)
Expand Down Expand Up @@ -372,3 +380,10 @@ runUI = interpret $ \_ -> \case
rootObj <- newObject rootClass ()

return rootObj

-- Helper function to extract nostr: references from content
extractNostrReferences :: Text -> [EventId]
extractNostrReferences txt =
let matches = txt =~ ("nostr:(note|nevent)1[a-zA-Z0-9]+" :: Text) :: [[Text]]
refs = mapMaybe (bech32ToEventId . drop 6 . head) matches -- drop "nostr:" prefix
in refs

0 comments on commit 1a91fd4

Please sign in to comment.