Skip to content

Commit

Permalink
Merge pull request #1436 from planetary-social/note-composer-preview
Browse files Browse the repository at this point in the history
Note Composer Preview
  • Loading branch information
martindsq authored Sep 9, 2024
2 parents 13f04f0 + 079ed24 commit 0487b8f
Show file tree
Hide file tree
Showing 19 changed files with 445 additions and 122 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Release Notes

- Added the option to preview a note before posting it.
- Fixed side menu accessibility issues.
- Fixed a bug where content of a quoted note expanded out beyond width of viewport.

Expand Down
6 changes: 6 additions & 0 deletions Nos.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,10 @@
5BBA5E912BADF98E00D57D76 /* AlreadyHaveANIP05View.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BBA5E902BADF98E00D57D76 /* AlreadyHaveANIP05View.swift */; };
5BBA5E9C2BAE052F00D57D76 /* NiceWorkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BBA5E9B2BAE052F00D57D76 /* NiceWorkSheet.swift */; };
5BC0D9CC2B867B9D005D6980 /* NamesAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC0D9CB2B867B9D005D6980 /* NamesAPI.swift */; };
5BCA95D22C8A5F0D00A52D1A /* PreviewEventRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */; };
5BD08BB22A38E96F00BB926C /* JSONRelayMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B503F612A291A1A0098805A /* JSONRelayMetadata.swift */; };
5BD25E592C192BBC005CF884 /* NoteParserTests+Parse.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BD25E582C192BBC005CF884 /* NoteParserTests+Parse.swift */; };
5BD813A32C8BA7CC00E65F4D /* PreviewEventRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */; };
5BE281C72AE2CCD800880466 /* ReplyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE281C62AE2CCD800880466 /* ReplyButton.swift */; };
5BE281CA2AE2CCEB00880466 /* HomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BE281C92AE2CCEB00880466 /* HomeTab.swift */; };
5BFBB28B2BD9D79F002E909F /* URLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BFBB28A2BD9D79F002E909F /* URLParser.swift */; };
Expand Down Expand Up @@ -677,6 +679,7 @@
5BBA5E902BADF98E00D57D76 /* AlreadyHaveANIP05View.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlreadyHaveANIP05View.swift; sourceTree = "<group>"; };
5BBA5E9B2BAE052F00D57D76 /* NiceWorkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NiceWorkSheet.swift; sourceTree = "<group>"; };
5BC0D9CB2B867B9D005D6980 /* NamesAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NamesAPI.swift; sourceTree = "<group>"; };
5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewEventRepository.swift; sourceTree = "<group>"; };
5BD25E582C192BBC005CF884 /* NoteParserTests+Parse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NoteParserTests+Parse.swift"; sourceTree = "<group>"; };
5BE281C62AE2CCD800880466 /* ReplyButton.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReplyButton.swift; sourceTree = "<group>"; };
5BE281C92AE2CCEB00880466 /* HomeTab.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeTab.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1739,6 +1742,7 @@
659B27232BD9CB4500BEA6CC /* VerifiableEvent.swift */,
030742C32B4769F90073839D /* CoreData */,
C92E7F652C4EFF2600B80638 /* WebSockets */,
5BCA95D12C8A5F0D00A52D1A /* PreviewEventRepository.swift */,
);
path = Models;
sourceTree = "<group>";
Expand Down Expand Up @@ -2312,6 +2316,7 @@
5B79F6132B98B145002DA9BE /* WizardNavigationStack.swift in Sources */,
C9F84C1C298DBBF400C6714D /* Data+Sha.swift in Sources */,
C936B4622A4CB01C00DF1EB9 /* PushNotificationService.swift in Sources */,
5BCA95D22C8A5F0D00A52D1A /* PreviewEventRepository.swift in Sources */,
C95D68A6299E6F9E00429F86 /* ProfileHeader.swift in Sources */,
0365CD872C4016A200622A1A /* EventKind.swift in Sources */,
C9BAB09B2996FBA10003A84E /* EventProcessor.swift in Sources */,
Expand Down Expand Up @@ -2407,6 +2412,7 @@
035729B32BE4167E005FEE85 /* TLVElementTests.swift in Sources */,
C9C2B77D29E072E400548B4A /* WebSocket+Nos.swift in Sources */,
C973AB642A323167002AED16 /* Relay+CoreDataProperties.swift in Sources */,
5BD813A32C8BA7CC00E65F4D /* PreviewEventRepository.swift in Sources */,
C9EE3E642A053910008A7491 /* ExpirationTimeOption.swift in Sources */,
65D066AA2BD55E160011C5CD /* DirectMessageWrapper.swift in Sources */,
C973AB5E2A323167002AED16 /* Event+CoreDataProperties.swift in Sources */,
Expand Down
12 changes: 12 additions & 0 deletions Nos/Assets/Localization/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -8854,6 +8854,18 @@
}
}
},
"preview" : {
"comment" : "verb for showing a preview of the typed note text",
"extractionState" : "manual",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Preview"
}
}
}
},
"privateKey" : {
"extractionState" : "manual",
"localizations" : {
Expand Down
12 changes: 9 additions & 3 deletions Nos/Extensions/Date+Elapsed.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,15 @@ extension Date {
return formattedDate
}

if let minute = components.minute, let formattedDate =
dateComponentsFormatter.string(from: DateComponents(calendar: calendar, minute: max(1, minute))) {
return formattedDate
if let minute = components.minute {
if minute >= 1 {
let dateComponents = DateComponents(calendar: calendar, minute: max(1, minute))
if let formattedDate = dateComponentsFormatter.string(from: dateComponents) {
return formattedDate
}
} else {
return String(localized: .localizable.now)
}
}

return formatLongDate(calendar)
Expand Down
31 changes: 28 additions & 3 deletions Nos/Models/CoreData/Event+CoreDataClass.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ public class Event: NosManagedObject, VerifiableEvent {
@Dependency(\.currentUser) @ObservationIgnored private var currentUser

var pubKey: String { author?.hexadecimalPublicKey ?? "" }


/// Event identifier for the note created by ``NoteComposer`` when displaying previews.
static let previewIdentifier = "preview"

static var replyNoteReferences = "kind = 1 AND ANY eventReferences.referencedEvent.identifier == %@ " +
"AND author.muted = false"

Expand Down Expand Up @@ -258,7 +261,18 @@ public class Event: NosManagedObject, VerifiableEvent {
fetchRequest.predicate = NSPredicate(format: "expirationDate <= %@", Date.now as CVarArg)
return fetchRequest
}


/// Builds a query that returns an Event with "preview" as its `identifier` if it exists.
/// - Returns: A Fetch Request with the necessary query inside.
@nonobjc public class func previewRequest() -> NSFetchRequest<Event> {
let fetchRequest = NSFetchRequest<Event>(entityName: "Event")
fetchRequest.predicate = NSPredicate(
format: "identifier = %@",
Event.previewIdentifier as CVarArg
)
return fetchRequest
}

@nonobjc public class func event(by identifier: RawEventID) -> NSFetchRequest<Event> {
let fetchRequest = NSFetchRequest<Event>(entityName: "Event")
fetchRequest.predicate = NSPredicate(format: "identifier = %@", identifier)
Expand Down Expand Up @@ -330,6 +344,10 @@ public class Event: NosManagedObject, VerifiableEvent {
" OR $reference.marker = 'reply'" +
" OR $reference.marker = nil" +
").@count = 0"
),
NSPredicate(
format: "identifier != %@",
Event.previewIdentifier as CVarArg
)
])
let kind6Predicate = NSPredicate(format: "kind = 6")
Expand Down Expand Up @@ -1074,7 +1092,14 @@ public class Event: NosManagedObject, VerifiableEvent {
var isReply: Bool {
rootNote() != nil || referencedNote() != nil
}


/// Returns `true` if this event is meant to be used to preview a note.
///
/// Used by ``NoteComposer``.
var isPreview: Bool {
identifier == Event.previewIdentifier
}

var isExpired: Bool {
if let expirationDate {
return expirationDate <= .now
Expand Down
41 changes: 40 additions & 1 deletion Nos/Models/JSONEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,46 @@ struct JSONEvent: Codable, Hashable, VerifiableEvent {
self.content = content
self.signature = ""
}


/// Initializes a JSONEvent object for a given text and key pair.
/// - Parameters:
/// - attributedText: The text the user wrote.
/// - noteParser: The algorithm that parses the text.
/// - expirationTime: The expiration time for the note, if any. Defaults to `nil`.
/// - replyToNote: The note that the user is replying to, if any. Defaults to `nil`.
/// - keyPair: Key pair of the logged in user.
init(
attributedText: AttributedString,
noteParser: NoteParser,
expirationTime: TimeInterval? = nil,
replyToNote: Event? = nil,
keyPair: KeyPair
) {
var (content, tags) = noteParser.parse(attributedText: attributedText)

if let expirationTime {
tags.append(["expiration", String(Date.now.timeIntervalSince1970 + expirationTime)])
}

// Attach the new note to the one it is replying to, if any.
if let replyToNote = replyToNote, let replyToNoteID = replyToNote.identifier {
// TODO: Append ptags for all authors involved in the thread
if let replyToAuthor = replyToNote.author?.publicKey?.hex {
tags.append(["p", replyToAuthor])
}

// If `note` is a reply to another root, tag that root
if let rootNoteIdentifier = replyToNote.rootNote()?.identifier, rootNoteIdentifier != replyToNoteID {
tags.append(["e", rootNoteIdentifier, "", EventReferenceMarker.root.rawValue])
tags.append(["e", replyToNoteID, "", EventReferenceMarker.reply.rawValue])
} else {
tags.append(["e", replyToNoteID, "", EventReferenceMarker.root.rawValue])
}
}

self.init(pubKey: keyPair.publicKeyHex, kind: .text, tags: tags, content: content)
}

static func from(json: String) -> JSONEvent? {
guard let jsonData = json.data(using: .utf8) else {
return nil
Expand Down
42 changes: 42 additions & 0 deletions Nos/Models/PreviewEventRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import CoreData

/// Provides a mechanism to store and retrieve an `Event` meant to be used to
/// preview a note the user is composing.
protocol PreviewEventRepository {
/// Creates a preview event object in the database.
/// - Parameters
/// - jsonEvent: JSONEvent object that holds the preview data.
/// - context: Managed object context that will hold the Preview event.
func createPreviewEvent(from jsonEvent: JSONEvent, in context: NSManagedObjectContext) throws -> Event?

/// Deletes the preview event object from the database.
/// - Parameters
/// - event: Preview event to delete from the database.
/// - context: Managed object context that holds the Preview event.
func deletePreviewEvent(_ event: Event, in context: NSManagedObjectContext) throws
}

/// Uses `CoreData` to store and retrieve an `Event` meant to be used to
/// preview a note the user is composing.
struct DefaultPreviewEventRepository: PreviewEventRepository {
func createPreviewEvent(from jsonEvent: JSONEvent, in context: NSManagedObjectContext) throws -> Event? {
if let oldPreviewEvent = Event.find(by: Event.previewIdentifier, context: context) {
try deletePreviewEvent(oldPreviewEvent, in: context)
}
var updatedJSONEvent = jsonEvent
updatedJSONEvent.id = Event.previewIdentifier
let newPreviewEvent = try EventProcessor.parse(
jsonEvent: updatedJSONEvent,
from: nil,
in: context,
skipVerification: true
)
try context.saveIfNeeded()
return newPreviewEvent
}

func deletePreviewEvent(_ event: Event, in context: NSManagedObjectContext) throws {
context.delete(event)
try context.saveIfNeeded()
}
}
10 changes: 10 additions & 0 deletions Nos/Service/CurrentUserError.swift
Original file line number Diff line number Diff line change
@@ -1,11 +1,21 @@
enum CurrentUserError: Error {
/// Author associated to the logged in user wasn't found.
/// It might indicate the user is not yet logged in.
case authorNotFound

/// KeyPair of the logged in user wasn't found.
/// It might indicate the user is not yet logged in.
case keyPairNotFound

/// Error while publishing to relays.
case errorWhilePublishingToRelays

var description: String? {
switch self {
case .authorNotFound:
return "Current user's author not found"
case .keyPairNotFound:
return "Current user's key pair not found"
case .errorWhilePublishingToRelays:
return "An encoding error happened while publishing to relays"
}
Expand Down
1 change: 1 addition & 0 deletions Nos/Service/DatabaseCleaner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ enum DatabaseCleaner {
// delete all events before deleteBefore that aren't protected or referenced
Event.cleanupRequest(before: deleteBefore, for: currentUser),
Event.expiredRequest(),
Event.previewRequest(),
EventReference.orphanedRequest(),
AuthorReference.orphanedRequest(),
Author.outOfNetwork(for: currentUser),
Expand Down
9 changes: 9 additions & 0 deletions Nos/Service/DependencyInjection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ extension DependencyValues {
get { self[MediaServiceKey.self] }
set { self[MediaServiceKey.self] = newValue }
}

var previewEventRepository: PreviewEventRepository {
get { self[PreviewEventRepositoryKey.self] }
set { self[PreviewEventRepositoryKey.self] = newValue }
}
}

fileprivate enum AnalyticsKey: DependencyKey {
Expand Down Expand Up @@ -187,3 +192,7 @@ fileprivate enum MediaServiceKey: DependencyKey {
static let testValue: any MediaService = MockMediaService()
static let previewValue: any MediaService = DefaultMediaService() // enables us to manually test with previews
}

fileprivate enum PreviewEventRepositoryKey: DependencyKey {
static let liveValue: any PreviewEventRepository = DefaultPreviewEventRepository()
}
1 change: 1 addition & 0 deletions Nos/Views/Components/Button/NoteButton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,7 @@ struct NoteButton: View {

ThreadRootView(
root: root,
isRootNoteInteractive: !note.isPreview,
tapAction: { root in router.push(root) },
reply: { compactButtonOrLabel }
)
Expand Down
25 changes: 24 additions & 1 deletion Nos/Views/Components/ThreadRootView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
import SwiftUI

/// Displays a reply note below its root note.
struct ThreadRootView<Reply: View>: View {

/// The root note.
var root: Event

/// Whether the root note is interactive.
var isRootNoteInteractive: Bool

/// Handler to be executed when the user taps on the root note.
var tapAction: ((Event) -> Void)?

/// Handler to be executed when building a View for displaying the reply note.
var reply: Reply

init(root: Event, tapAction: ((Event) -> Void)?, @ViewBuilder reply: () -> Reply) {
/// Initializes a Thread Root View.
/// - Parameters:
/// - root: The root note.
/// - isRootNoteInteractive: Whether the root note is interactive. Defaults to `true`.
/// - tapAction: Handler to be executed when the user taps on the root note.
/// - reply: Handler to be executed when building a View for displaying the reply note.
init(
root: Event,
isRootNoteInteractive: Bool = true,
tapAction: ((Event) -> Void)?,
@ViewBuilder reply: () -> Reply
) {
self.root = root
self.isRootNoteInteractive = isRootNoteInteractive
self.tapAction = tapAction
self.reply = reply()
}
Expand All @@ -21,6 +43,7 @@ struct ThreadRootView<Reply: View>: View {
)
.padding(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.readabilityPadding()
.allowsHitTesting(isRootNoteInteractive)
.opacity(0.7)
.frame(height: 100, alignment: .top)
.clipped()
Expand Down
3 changes: 3 additions & 0 deletions Nos/Views/Note/CompactNoteView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,12 @@ struct CompactNoteView: View {
if !isTextTruncated || !shouldTruncate {
formattedText
.fixedSize(horizontal: false, vertical: true)
.allowsHitTesting(!note.isPreview)
} else {
formattedText
.fixedSize(horizontal: false, vertical: true)
.lineLimit(truncationLineLimit)
.allowsHitTesting(!note.isPreview)
.background {
GeometryReader { geometryProxy in
Color.clear.preference(key: TruncatedSizePreferenceKey.self, value: geometryProxy.size)
Expand Down Expand Up @@ -150,6 +152,7 @@ struct CompactNoteView: View {
}
.frame(maxWidth: .infinity)
.padding(EdgeInsets(top: 0, leading: 0, bottom: 10, trailing: 0))
.allowsHitTesting(!note.isPreview)
}
if note.kind == EventKind.text.rawValue, showLinkPreviews, !note.contentLinks.isEmpty {
if featureFlags.newMediaDisplayEnabled {
Expand Down
3 changes: 3 additions & 0 deletions Nos/Views/Note/NoteCard.swift
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ struct NoteCard: View {
Spacer()
}
}
.allowsHitTesting(!note.isPreview)
Divider().overlay(Color.cardDividerTop).shadow(color: .cardDividerTopShadow, radius: 0, x: 0, y: 1)
Group {
if note.isStub {
Expand Down Expand Up @@ -120,6 +121,7 @@ struct NoteCard: View {
.padding(.horizontal, 16)
.padding(.bottom, 16)
}
.allowsHitTesting(!note.isPreview)
}
}
BeveledSeparator()
Expand All @@ -142,6 +144,7 @@ struct NoteCard: View {
}
}
.padding(.leading, 13)
.allowsHitTesting(!note.isPreview)
}
}
.blur(radius: warningController.showWarning ? 6 : 0)
Expand Down
Loading

0 comments on commit 0487b8f

Please sign in to comment.