Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb4b12f633 | |||
| c00d8b62dd | |||
| 0d60426da2 | |||
| fec46dbd52 | |||
| 54da63c4a1 | |||
| ec1200faea | |||
| f0825e09d5 | |||
| 1e354c2675 | |||
| eade5ac4c8 | |||
| 8a33f9529b | |||
| 4abeb93ac7 | |||
| 619f84d89e | |||
| 9531315c94 | |||
| cfafbbcb14 | |||
| 6a6096e0b8 | |||
| 1480a44dfb | |||
| 2687d175c1 | |||
| c84682d3d9 | |||
| 6627936d23 | |||
| b26ee135bc | |||
| 96d7da82bb | |||
| 9707c7cfe4 | |||
| 4968853f8c | |||
| 053735d408 | |||
| 6052398040 | |||
| df48ce7b85 | |||
| eb44646978 | |||
| 6686d8f83b | |||
| 03be7479f4 | |||
| 3b54e8959d | |||
| 69813ee7fe | |||
| 4ccab620d6 | |||
| 3ea5a88e01 | |||
| 2b9216b1b8 | |||
| 48f307b003 | |||
| eb44c67f69 | |||
| e6452f116e | |||
| 96c660a424 | |||
| 4833dcde35 | |||
| 8f907c1fe9 | |||
| e314fea3e7 | |||
| b4ec75ca27 |
@@ -0,0 +1 @@
|
||||
3.0
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
osx_image: xcode8
|
||||
language: objective-c
|
||||
osx_image: xcode8.3
|
||||
language: swift
|
||||
|
||||
before_install:
|
||||
- brew update
|
||||
|
||||
@@ -164,13 +164,16 @@
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-DemoLightbox-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_ROOT}/../Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n";
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3BCDC27EE322C46C109D231B /* [CP] Copy Pods Resources */ = {
|
||||
@@ -194,9 +197,14 @@
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${SRCROOT}/Pods/Target Support Files/Pods-DemoLightbox/Pods-DemoLightbox-frameworks.sh",
|
||||
"${BUILT_PRODUCTS_DIR}/Hue/Hue.framework",
|
||||
"${BUILT_PRODUCTS_DIR}/Lightbox/Lightbox.framework",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Hue.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Lightbox.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
PODS:
|
||||
- Hue (2.0.1)
|
||||
- Lightbox (1.0.0):
|
||||
- Hue
|
||||
- Hue (~> 2.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- Lightbox (from `../../`)
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
Lightbox:
|
||||
:path: "../../"
|
||||
:path: ../../
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
Hue: 354caec055fdc9d38b5ef33ca2e7224721843baf
|
||||
Lightbox: eb4196a846a883c4c2a52209b29b283d1bc706e1
|
||||
Lightbox: d15b5e265e505009932fa447f27b5262b8b1b604
|
||||
|
||||
PODFILE CHECKSUM: cd88b68c201e5c39cef62070056649eaee91c71b
|
||||
|
||||
COCOAPODS: 1.1.1
|
||||
COCOAPODS: 1.3.1
|
||||
|
||||
+4
-4
@@ -1,7 +1,7 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = "Lightbox"
|
||||
s.summary = "A short description of Lightbox."
|
||||
s.version = "1.0.0"
|
||||
s.summary = "A a convenient and easy to use image viewer for your iOS app, packed with all the features you expect"
|
||||
s.version = "1.1.0"
|
||||
s.homepage = "https://github.com/hyperoslo/Lightbox"
|
||||
s.license = 'MIT'
|
||||
s.author = { "Hyper Interaktiv AS" => "ios@hyper.no" }
|
||||
@@ -13,7 +13,7 @@ Pod::Spec.new do |s|
|
||||
s.ios.resource = 'Resources/Lightbox.bundle'
|
||||
|
||||
s.frameworks = 'UIKit', 'AVFoundation', 'AVKit'
|
||||
s.dependency 'Hue'
|
||||
|
||||
s.dependency 'Hue', '~> 2.0'
|
||||
|
||||
s.pod_target_xcconfig = { 'SWIFT_VERSION' => '3.0' }
|
||||
end
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
D523B0A61C43AA2A001AD1EC /* Headers */,
|
||||
D523B0A71C43AA2A001AD1EC /* Resources */,
|
||||
D54DFCC41C5AAAF600ADEA0E /* Copy frameworks with Carthage */,
|
||||
5CF8A88D1F50B4EA00C28475 /* ShellScript */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -200,6 +201,19 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
5CF8A88D1F50B4EA00C28475 /* ShellScript */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "if which swiftlint >/dev/null; then\nswiftlint\nelse\necho \"warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint\"\nfi";
|
||||
};
|
||||
D54DFCC41C5AAAF600ADEA0E /* Copy frameworks with Carthage */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
[](http://cocoadocs.org/docsets/Lightbox)
|
||||

|
||||
|
||||
[Demo](https://appetize.io/app/wfgwc2uvg82m9pzbt17p4rrgh4?device=iphone5s&scale=75&orientation=portrait&osVersion=9.3)
|
||||
|
||||
<img src="https://raw.githubusercontent.com/hyperoslo/Lightbox/master/Images/Icon.png" alt="Lightbox Icon" align="right" />
|
||||
|
||||
**Lightbox** is a convenient and easy to use image viewer for your iOS app,
|
||||
|
||||
@@ -111,8 +111,8 @@ extension LightboxTransition: UIViewControllerAnimatedTransitioning {
|
||||
func animateTransition(using transitionContext: UIViewControllerContextTransitioning) {
|
||||
let container = transitionContext.containerView
|
||||
|
||||
guard let fromView = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.from)?.view,
|
||||
let toView = transitionContext.viewController(forKey: UITransitionContextViewControllerKey.to)?.view
|
||||
guard let fromView = transitionContext.view(forKey: UITransitionContextViewKey.from),
|
||||
let toView = transitionContext.view(forKey: UITransitionContextViewKey.to)
|
||||
else { return }
|
||||
|
||||
let firstView = dismissing ? toView : fromView
|
||||
@@ -123,6 +123,8 @@ extension LightboxTransition: UIViewControllerAnimatedTransitioning {
|
||||
container.addSubview(firstView)
|
||||
container.addSubview(secondView)
|
||||
|
||||
toView.frame = container.bounds
|
||||
|
||||
let duration = transitionDuration(using: transitionContext)
|
||||
|
||||
UIView.animate(withDuration: duration, animations: {
|
||||
@@ -145,8 +147,8 @@ extension LightboxTransition: UIViewControllerTransitioningDelegate {
|
||||
}
|
||||
|
||||
func animationController(forPresented presented: UIViewController,
|
||||
presenting: UIViewController,
|
||||
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
presenting: UIViewController,
|
||||
source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
|
||||
dismissing = false
|
||||
return self
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import UIKit
|
||||
|
||||
extension UIView {
|
||||
|
||||
func addGradientLayer(_ colors: [UIColor]) -> CAGradientLayer {
|
||||
@discardableResult func addGradientLayer(_ colors: [UIColor]) -> CAGradientLayer {
|
||||
if let gradientLayer = gradientLayer { return gradientLayer }
|
||||
|
||||
let gradient = CAGradientLayer()
|
||||
|
||||
@@ -15,7 +15,7 @@ open class LightboxConfig {
|
||||
|
||||
NSURLConnection.sendAsynchronousRequest(imageRequest,
|
||||
queue: OperationQueue.main,
|
||||
completionHandler: { response, data, error in
|
||||
completionHandler: { _, data, error in
|
||||
if let data = data, let image = UIImage(data: data) {
|
||||
imageView.image = image
|
||||
}
|
||||
@@ -50,7 +50,7 @@ open class LightboxConfig {
|
||||
|
||||
public struct CloseButton {
|
||||
public static var enabled = true
|
||||
public static var size = CGSize(width: 60, height: 25)
|
||||
public static var size: CGSize?
|
||||
public static var text = NSLocalizedString("Close", comment: "")
|
||||
public static var image: UIImage?
|
||||
|
||||
@@ -67,7 +67,7 @@ open class LightboxConfig {
|
||||
|
||||
public struct DeleteButton {
|
||||
public static var enabled = false
|
||||
public static var size = CGSize(width: 70, height: 25)
|
||||
public static var size: CGSize?
|
||||
public static var text = NSLocalizedString("Delete", comment: "")
|
||||
public static var image: UIImage?
|
||||
|
||||
@@ -98,8 +98,8 @@ open class LightboxConfig {
|
||||
public static var minimumScale: CGFloat = 1.0
|
||||
public static var maximumScale: CGFloat = 3.0
|
||||
}
|
||||
|
||||
|
||||
public struct LoadingIndicator {
|
||||
public static var configure: ((UIActivityIndicatorView) -> Void)? = nil
|
||||
public static var configure: ((UIActivityIndicatorView) -> Void)?
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,11 @@ public protocol LightboxControllerDismissalDelegate: class {
|
||||
func lightboxControllerWillDismiss(_ controller: LightboxController)
|
||||
}
|
||||
|
||||
public protocol LightboxControllerTouchDelegate: class {
|
||||
|
||||
func lightboxController(_ controller: LightboxController, didTouch image: LightboxImage, at index: Int)
|
||||
}
|
||||
|
||||
open class LightboxController: UIViewController {
|
||||
|
||||
// MARK: - Internal views
|
||||
@@ -70,14 +75,14 @@ open class LightboxController: UIViewController {
|
||||
let gradient = CAGradientLayer()
|
||||
let colors = [UIColor(hex: "090909").alpha(0), UIColor(hex: "040404")]
|
||||
|
||||
_ = view.addGradientLayer(colors)
|
||||
view.addGradientLayer(colors)
|
||||
view.alpha = 0
|
||||
|
||||
return view
|
||||
}()
|
||||
|
||||
var screenBounds: CGRect {
|
||||
return UIScreen.main.bounds
|
||||
return UIApplication.shared.delegate?.window??.bounds ?? .zero
|
||||
}
|
||||
|
||||
// MARK: - Properties
|
||||
@@ -94,7 +99,7 @@ open class LightboxController: UIViewController {
|
||||
|
||||
pageDelegate?.lightboxController(self, didMoveToPage: currentPage)
|
||||
|
||||
if let image = pageViews[currentPage].imageView.image , dynamicBackground {
|
||||
if let image = pageViews[currentPage].imageView.image, dynamicBackground {
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.125) {
|
||||
self.loadDynamicBackground(image)
|
||||
}
|
||||
@@ -137,6 +142,7 @@ open class LightboxController: UIViewController {
|
||||
|
||||
open weak var pageDelegate: LightboxControllerPageDelegate?
|
||||
open weak var dismissalDelegate: LightboxControllerDismissalDelegate?
|
||||
open weak var imageTouchDelegate: LightboxControllerTouchDelegate?
|
||||
open internal(set) var presented = false
|
||||
open fileprivate(set) var seen = false
|
||||
|
||||
@@ -182,23 +188,14 @@ open class LightboxController: UIViewController {
|
||||
|
||||
open override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
|
||||
if LightboxConfig.hideStatusBar {
|
||||
UIApplication.shared.setStatusBarHidden(true, with: .fade)
|
||||
}
|
||||
|
||||
if !presented {
|
||||
presented = true
|
||||
configureLayout()
|
||||
}
|
||||
}
|
||||
|
||||
open override func viewDidDisappear(_ animated: Bool) {
|
||||
super.viewDidDisappear(animated)
|
||||
|
||||
if LightboxConfig.hideStatusBar {
|
||||
UIApplication.shared.setStatusBarHidden(statusBarHidden, with: .fade)
|
||||
}
|
||||
open override var prefersStatusBarHidden: Bool {
|
||||
return LightboxConfig.hideStatusBar
|
||||
}
|
||||
|
||||
// MARK: - Rotation
|
||||
@@ -206,9 +203,9 @@ open class LightboxController: UIViewController {
|
||||
override open func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
super.viewWillTransition(to: size, with: coordinator)
|
||||
|
||||
coordinator.animate(alongsideTransition: { (UIViewControllerTransitionCoordinatorContext) -> Void in
|
||||
coordinator.animate(alongsideTransition: { _ in
|
||||
self.configureLayout(size)
|
||||
}, completion: nil)
|
||||
}, completion: nil)
|
||||
}
|
||||
|
||||
// MARK: - Configuration
|
||||
@@ -240,7 +237,9 @@ open class LightboxController: UIViewController {
|
||||
var offset = scrollView.contentOffset
|
||||
offset.x = CGFloat(page) * (scrollView.frame.width + spacing)
|
||||
|
||||
scrollView.setContentOffset(offset, animated: animated)
|
||||
let shouldAnimated = view.window != nil ? animated : false
|
||||
|
||||
scrollView.setContentOffset(offset, animated: shouldAnimated)
|
||||
}
|
||||
|
||||
open func next(_ animated: Bool = true) {
|
||||
@@ -259,7 +258,7 @@ open class LightboxController: UIViewController {
|
||||
|
||||
// MARK: - Layout
|
||||
|
||||
open func configureLayout(_ size: CGSize = UIScreen.main.bounds.size) {
|
||||
open func configureLayout(_ size: CGSize = UIApplication.shared.delegate?.window??.bounds.size ?? .zero) {
|
||||
scrollView.frame.size = size
|
||||
scrollView.contentSize = CGSize(
|
||||
width: size.width * CGFloat(numberOfPages) + spacing * CGFloat(numberOfPages - 1),
|
||||
@@ -341,8 +340,16 @@ extension LightboxController: UIScrollViewDelegate {
|
||||
|
||||
extension LightboxController: PageViewDelegate {
|
||||
|
||||
func remoteImageDidLoad(_ image: UIImage?) {
|
||||
guard let image = image , dynamicBackground else { return }
|
||||
func remoteImageDidLoad(_ image: UIImage?, imageView: UIImageView) {
|
||||
guard let image = image, dynamicBackground else {
|
||||
return
|
||||
}
|
||||
|
||||
let imageViewFrame = imageView.convert(imageView.frame, to: view)
|
||||
guard view.frame.intersects(imageViewFrame) else {
|
||||
return
|
||||
}
|
||||
|
||||
loadDynamicBackground(image)
|
||||
}
|
||||
|
||||
@@ -358,6 +365,8 @@ extension LightboxController: PageViewDelegate {
|
||||
func pageViewDidTouch(_ pageView: PageView) {
|
||||
guard !pageView.hasZoomed else { return }
|
||||
|
||||
imageTouchDelegate?.lightboxController(self, didTouch: images[currentPage], at: currentPage)
|
||||
|
||||
let visible = (headerView.alpha == 1.0)
|
||||
toggleControls(pageView: pageView, visible: !visible)
|
||||
}
|
||||
@@ -412,6 +421,6 @@ extension LightboxController: FooterViewDelegate {
|
||||
UIView.animate(withDuration: 0.25, animations: {
|
||||
self.overlayView.alpha = expanded ? 1.0 : 0.0
|
||||
self.headerView.deleteButton.alpha = expanded ? 0.0 : 1.0
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ open class LightboxImage {
|
||||
self.videoURL = videoURL
|
||||
}
|
||||
|
||||
public init(imageURL: URL, text: String = "", videoURL: URL? = nil ) {
|
||||
public init(imageURL: URL, text: String = "", videoURL: URL? = nil) {
|
||||
self.imageURL = imageURL
|
||||
self.text = text
|
||||
self.videoURL = videoURL
|
||||
@@ -26,7 +26,8 @@ open class LightboxImage {
|
||||
imageView.image = image
|
||||
completion?(image)
|
||||
} else if let imageURL = imageURL {
|
||||
LightboxConfig.loadImage(imageView, imageURL) { error, image in
|
||||
LightboxConfig.loadImage(imageView, imageURL) { [weak self] _, image in
|
||||
self?.image = image
|
||||
completion?(image)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ extension FooterView: InfoLabelDelegate {
|
||||
|
||||
public func infoLabel(_ infoLabel: InfoLabel, didExpand expanded: Bool) {
|
||||
resetFrames()
|
||||
_ = expanded ? removeGradientLayer() : addGradientLayer(gradientColors)
|
||||
_ = (expanded || infoLabel.fullText.isEmpty) ? removeGradientLayer() : addGradientLayer(gradientColors)
|
||||
delegate?.footerView(self, didExpand: expanded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,14 @@ open class HeaderView: UIView {
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
|
||||
button.frame.size = LightboxConfig.CloseButton.size
|
||||
button.setAttributedTitle(title, for: UIControlState())
|
||||
|
||||
if let size = LightboxConfig.CloseButton.size {
|
||||
button.frame.size = size
|
||||
} else {
|
||||
button.sizeToFit()
|
||||
}
|
||||
|
||||
button.addTarget(self, action: #selector(closeButtonDidPress(_:)),
|
||||
for: .touchUpInside)
|
||||
|
||||
@@ -41,8 +47,14 @@ open class HeaderView: UIView {
|
||||
|
||||
let button = UIButton(type: .system)
|
||||
|
||||
button.frame.size = LightboxConfig.DeleteButton.size
|
||||
button.setAttributedTitle(title, for: .normal)
|
||||
|
||||
if let size = LightboxConfig.DeleteButton.size {
|
||||
button.frame.size = size
|
||||
} else {
|
||||
button.sizeToFit()
|
||||
}
|
||||
|
||||
button.addTarget(self, action: #selector(deleteButtonDidPress(_:)),
|
||||
for: .touchUpInside)
|
||||
|
||||
|
||||
@@ -48,6 +48,10 @@ open class InfoLabel: UILabel {
|
||||
return truncatedText
|
||||
}
|
||||
|
||||
while numberOfLines(truncatedText) > numberOfVisibleLines * 2 {
|
||||
truncatedText = String(truncatedText.characters.prefix(truncatedText.characters.count / 2))
|
||||
}
|
||||
|
||||
truncatedText += ellipsis
|
||||
|
||||
let start = truncatedText.characters.index(truncatedText.endIndex, offsetBy: -(ellipsis.characters.count + 1))
|
||||
@@ -119,7 +123,7 @@ open class InfoLabel: UILabel {
|
||||
return string.boundingRect(
|
||||
with: CGSize(width: bounds.size.width, height: CGFloat.greatestFiniteMagnitude),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
||||
attributes: [NSFontAttributeName : font],
|
||||
attributes: [NSFontAttributeName: font],
|
||||
context: nil).height
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import UIKit
|
||||
protocol PageViewDelegate: class {
|
||||
|
||||
func pageViewDidZoom(_ pageView: PageView)
|
||||
func remoteImageDidLoad(_ image: UIImage?)
|
||||
func remoteImageDidLoad(_ image: UIImage?, imageView: UIImageView)
|
||||
func pageView(_ pageView: PageView, didTouchPlayButton videoURL: URL)
|
||||
func pageViewDidTouch(_ pageView: PageView)
|
||||
}
|
||||
@@ -52,13 +52,17 @@ class PageView: UIScrollView {
|
||||
configure()
|
||||
|
||||
activityIndicator.alpha = 1
|
||||
self.image.addImageTo(imageView) { image in
|
||||
self.isUserInteractionEnabled = true
|
||||
self.configureImageView()
|
||||
self.pageViewDelegate?.remoteImageDidLoad(image)
|
||||
self.image.addImageTo(imageView) { [weak self] image in
|
||||
guard let strongSelf = self else {
|
||||
return
|
||||
}
|
||||
|
||||
strongSelf.isUserInteractionEnabled = true
|
||||
strongSelf.configureImageView()
|
||||
strongSelf.pageViewDelegate?.remoteImageDidLoad(image, imageView: strongSelf.imageView)
|
||||
|
||||
UIView.animate(withDuration: 0.4) {
|
||||
self.activityIndicator.alpha = 0
|
||||
strongSelf.activityIndicator.alpha = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,7 +132,10 @@ class PageView: UIScrollView {
|
||||
}
|
||||
|
||||
func configureImageView() {
|
||||
guard let image = imageView.image else { return }
|
||||
guard let image = imageView.image else {
|
||||
centerImageView()
|
||||
return
|
||||
}
|
||||
|
||||
let imageViewSize = imageView.frame.size
|
||||
let imageSize = image.size
|
||||
|
||||
Reference in New Issue
Block a user