Add PreviewManger with Core Data cache and a URLPreviewView with a view model.

Changes to RoomDataSource still to come.
This commit is contained in:
Doug
2021-08-23 17:56:24 +01:00
parent eaa853d33f
commit dd600e5e7e
18 changed files with 952 additions and 19 deletions
@@ -0,0 +1,145 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
import Reusable
@objcMembers
class URLPreviewView: UIView, NibLoadable, Themable {
// MARK: - Constants
private enum Constants { }
// MARK: - Properties
var viewModel: URLPreviewViewModel! {
didSet {
viewModel.viewDelegate = self
}
}
@IBOutlet weak var imageView: UIImageView!
@IBOutlet weak var faviconImageView: UIImageView!
@IBOutlet weak var siteNameLabel: UILabel!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!
override var intrinsicContentSize: CGSize {
CGSize(width: RoomBubbleCellLayout.urlPreviewViewWidth, height: RoomBubbleCellLayout.urlPreviewViewHeight)
}
// MARK: - Setup
static func instantiate(viewModel: URLPreviewViewModel) -> Self {
let view = Self.loadFromNib()
view.update(theme: ThemeService.shared().theme)
view.viewModel = viewModel
viewModel.process(viewAction: .loadData)
return view
}
// MARK: - Life cycle
override func awakeFromNib() {
super.awakeFromNib()
layer.cornerRadius = 8
layer.masksToBounds = true
imageView.contentMode = .scaleAspectFill
faviconImageView.layer.cornerRadius = 6
siteNameLabel.isUserInteractionEnabled = false
titleLabel.isUserInteractionEnabled = false
descriptionLabel.isUserInteractionEnabled = false
#warning("Debugging for previews - to be removed")
faviconImageView.backgroundColor = .systemBlue.withAlphaComponent(0.7)
}
// MARK: - Public
func update(theme: Theme) {
backgroundColor = theme.colors.navigation
siteNameLabel.textColor = theme.colors.secondaryContent
siteNameLabel.font = theme.fonts.caption2SB
titleLabel.textColor = theme.colors.primaryContent
titleLabel.font = theme.fonts.calloutSB
descriptionLabel.textColor = theme.colors.secondaryContent
descriptionLabel.font = theme.fonts.caption1
}
// MARK: - Private
private func renderLoading(_ url: URL) {
imageView.image = nil
siteNameLabel.text = url.host
titleLabel.text = "Loading..."
descriptionLabel.text = ""
}
private func renderLoaded(_ preview: URLPreviewViewData) {
imageView.image = preview.image
siteNameLabel.text = preview.siteName ?? preview.url.host
titleLabel.text = preview.title
descriptionLabel.text = preview.text
}
private func renderError(_ error: Error) {
imageView.image = nil
siteNameLabel.text = "Error"
titleLabel.text = descriptionLabel.text
descriptionLabel.text = error.localizedDescription
}
// MARK: - Action
@IBAction private func openURL(_ sender: Any) {
MXLog.debug("[URLPreviewView] Link was tapped.")
viewModel.process(viewAction: .openURL)
}
@IBAction private func close(_ sender: Any) {
}
}
// MARK: URLPreviewViewModelViewDelegate
extension URLPreviewView: URLPreviewViewModelViewDelegate {
func urlPreviewViewModel(_ viewModel: URLPreviewViewModelType, didUpdateViewState viewState: URLPreviewViewState) {
DispatchQueue.main.async {
switch viewState {
case .loading(let url):
self.renderLoading(url)
case .loaded(let preview):
self.renderLoaded(preview)
case .error(let error):
self.renderError(error)
case .hidden:
self.frame.size.height = 0
}
}
}
}
@@ -0,0 +1,118 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view contentMode="scaleToFill" id="iN0-l3-epB" customClass="URLPreviewView" customModule="Riot" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="267" height="247"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="sV7-z8-2ZW">
<rect key="frame" x="0.0" y="0.0" width="267" height="140"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="9ew-qc-5BO">
<rect key="frame" x="0.0" y="0.0" width="267" height="140"/>
</imageView>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
<constraints>
<constraint firstAttribute="width" constant="267" id="1cA-Oe-Ffr"/>
<constraint firstItem="9ew-qc-5BO" firstAttribute="top" secondItem="sV7-z8-2ZW" secondAttribute="top" id="PkK-wn-hNC"/>
<constraint firstItem="9ew-qc-5BO" firstAttribute="leading" secondItem="sV7-z8-2ZW" secondAttribute="leading" id="bQt-a9-prT"/>
<constraint firstAttribute="bottom" secondItem="9ew-qc-5BO" secondAttribute="bottom" id="dfh-UN-9f8"/>
<constraint firstAttribute="trailing" secondItem="9ew-qc-5BO" secondAttribute="trailing" id="mRi-du-dck"/>
<constraint firstAttribute="height" constant="140" id="ozL-dw-rED"/>
</constraints>
<connections>
<outletCollection property="gestureRecognizers" destination="rSB-1V-Kev" appends="YES" id="LLc-zz-Ooa"/>
</connections>
</view>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="78m-NG-Oe7">
<rect key="frame" x="8" y="108" width="24" height="24"/>
<constraints>
<constraint firstAttribute="width" constant="24" id="54e-3E-QhC"/>
<constraint firstAttribute="height" constant="24" id="Ms9-sP-ceF"/>
</constraints>
</imageView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Site Name" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="2D0-pg-81F">
<rect key="frame" x="8" y="148" width="56" height="13.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="11"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="250" text="Title" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="2" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="bfQ-4X-PGU">
<rect key="frame" x="8" y="163.5" width="33.5" height="19.5"/>
<fontDescription key="fontDescription" type="system" weight="semibold" pointSize="16"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="250" text="Description" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="3" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="cSk-qu-c4j">
<rect key="frame" x="8" y="185" width="251" height="14.5"/>
<fontDescription key="fontDescription" type="system" pointSize="12"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" fixedFrame="YES" contentHorizontalAlignment="center" contentVerticalAlignment="center" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="CL2-R3-bYG">
<rect key="frame" x="242" y="9" width="10" height="22"/>
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
<state key="normal" image="close_banner">
<preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="large"/>
</state>
<connections>
<action selector="close:" destination="iN0-l3-epB" eventType="touchUpInside" id="iNI-q6-N3g"/>
</connections>
</button>
</subviews>
<color key="backgroundColor" systemColor="tertiarySystemFillColor"/>
<gestureRecognizers/>
<constraints>
<constraint firstAttribute="trailing" secondItem="cSk-qu-c4j" secondAttribute="trailing" constant="8" id="1QZ-IV-b1E"/>
<constraint firstAttribute="top" secondItem="sV7-z8-2ZW" secondAttribute="top" id="8AQ-S7-Fm0"/>
<constraint firstItem="2D0-pg-81F" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="8" id="A6d-x8-mDG"/>
<constraint firstItem="cSk-qu-c4j" firstAttribute="leading" secondItem="bfQ-4X-PGU" secondAttribute="leading" id="IT4-7h-n5y"/>
<constraint firstItem="bfQ-4X-PGU" firstAttribute="leading" secondItem="2D0-pg-81F" secondAttribute="leading" id="Qxt-SR-d1M"/>
<constraint firstItem="bfQ-4X-PGU" firstAttribute="top" secondItem="2D0-pg-81F" secondAttribute="bottom" constant="2" id="ZBc-MM-pl2"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="bfQ-4X-PGU" secondAttribute="trailing" constant="8" id="bk2-2s-hgS"/>
<constraint firstItem="sV7-z8-2ZW" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" id="cfa-wh-EzA"/>
<constraint firstItem="78m-NG-Oe7" firstAttribute="leading" secondItem="sV7-z8-2ZW" secondAttribute="leading" constant="8" id="ecO-IN-wTm"/>
<constraint firstAttribute="trailing" secondItem="sV7-z8-2ZW" secondAttribute="trailing" id="gUP-5s-qWI"/>
<constraint firstItem="2D0-pg-81F" firstAttribute="top" secondItem="sV7-z8-2ZW" secondAttribute="bottom" constant="8" id="neh-8P-aFx"/>
<constraint firstAttribute="trailing" relation="greaterThanOrEqual" secondItem="2D0-pg-81F" secondAttribute="trailing" constant="8" id="tnX-Fo-b79"/>
<constraint firstItem="cSk-qu-c4j" firstAttribute="top" secondItem="bfQ-4X-PGU" secondAttribute="bottom" constant="2" id="xp1-2j-xl8"/>
<constraint firstAttribute="bottom" relation="greaterThanOrEqual" secondItem="cSk-qu-c4j" secondAttribute="bottom" constant="8" id="yHd-Fp-jPo"/>
<constraint firstItem="sV7-z8-2ZW" firstAttribute="bottom" secondItem="78m-NG-Oe7" secondAttribute="bottom" constant="8" id="zsc-zO-UkD"/>
</constraints>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<connections>
<outlet property="descriptionLabel" destination="cSk-qu-c4j" id="gC7-Pu-nHx"/>
<outlet property="faviconImageView" destination="78m-NG-Oe7" id="u26-zc-JwX"/>
<outlet property="imageView" destination="9ew-qc-5BO" id="nzz-xV-mae"/>
<outlet property="siteNameLabel" destination="2D0-pg-81F" id="72o-l1-f7x"/>
<outlet property="titleLabel" destination="bfQ-4X-PGU" id="Jzt-75-caa"/>
<outletCollection property="gestureRecognizers" destination="rSB-1V-Kev" appends="YES" id="qPD-mR-YpO"/>
</connections>
<point key="canvasLocation" x="131.15942028985509" y="73.995535714285708"/>
</view>
<tapGestureRecognizer id="rSB-1V-Kev">
<connections>
<action selector="openURL:" destination="iN0-l3-epB" id="sUF-br-ODY"/>
</connections>
</tapGestureRecognizer>
</objects>
<resources>
<image name="close_banner" width="10" height="10"/>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
<systemColor name="tertiarySystemFillColor">
<color red="0.46274509803921571" green="0.46274509803921571" blue="0.50196078431372548" alpha="0.12" colorSpace="custom" customColorSpace="sRGB"/>
</systemColor>
</resources>
</document>
@@ -0,0 +1,24 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// URLPreviewView actions exposed to view model
enum URLPreviewViewAction {
case loadData
case openURL
case close
}
@@ -0,0 +1,42 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
@objc
class URLPreviewViewData: NSObject {
/// The URL that's represented by the preview data.
let url: URL
/// The OpenGraph site name for the URL.
let siteName: String?
/// The OpenGraph title for the URL.
let title: String?
/// The OpenGraph description for the URL.
let text: String?
/// The OpenGraph image for the URL.
var image: UIImage?
init(url: URL, siteName: String?, title: String?, text: String?) {
self.url = url
self.siteName = siteName
self.title = title
self.text = text
}
}
@@ -0,0 +1,89 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
import MatrixSDK
@objcMembers
class URLPreviewViewModel: NSObject, URLPreviewViewModelType {
// MARK: - Properties
// MARK: Private
private let url: URL
private let session: MXSession
private var currentOperation: MXHTTPOperation?
private var urlPreview: MXURLPreview?
// MARK: Public
weak var viewDelegate: URLPreviewViewModelViewDelegate?
// MARK: - Setup
init(url: URL, session: MXSession) {
self.url = url
self.session = session
}
deinit {
cancelOperations()
}
// MARK: - Public
func process(viewAction: URLPreviewViewAction) {
switch viewAction {
case .loadData:
loadData()
case .openURL:
openURL()
case .close:
cancelOperations()
}
}
// MARK: - Private
private func loadData() {
update(viewState: .loading(url))
AppDelegate.theDelegate().previewManager.preview(for: url) { [weak self] preview in
guard let self = self else { return }
self.update(viewState: .loaded(preview))
} failure: { error in
#warning("REALLY?!")
if let error = error {
self.update(viewState: .error(error))
}
}
}
private func openURL() {
UIApplication.shared.open(url)
}
private func update(viewState: URLPreviewViewState) {
viewDelegate?.urlPreviewViewModel(self, didUpdateViewState: viewState)
}
private func cancelOperations() {
currentOperation?.cancel()
}
}
@@ -0,0 +1,29 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol URLPreviewViewModelViewDelegate: AnyObject {
func urlPreviewViewModel(_ viewModel: URLPreviewViewModelType, didUpdateViewState viewState: URLPreviewViewState)
}
/// Protocol describing the view model used by `URLPreviewView`
protocol URLPreviewViewModelType {
var viewDelegate: URLPreviewViewModelViewDelegate? { get set }
func process(viewAction: URLPreviewViewAction)
}
@@ -0,0 +1,25 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
/// URLPreviewView state
enum URLPreviewViewState {
case loading(_ url: URL)
case loaded(_ preview: URLPreviewViewData)
case error(Error)
case hidden
}