mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
Merge commit 'ee54a4a403ead54d700d79521e2dd0bf8832918f'
This commit is contained in:
commit
7939351e0b
@ -1162,9 +1162,9 @@ private class ImageRecognitionOverlayContentNode: GalleryOverlayContentNode {
|
||||
self.interfaceIsHidden = isHidden
|
||||
|
||||
let buttonSize = CGSize(width: 32.0, height: 32.0)
|
||||
self.backgroundNode.frame = CGRect(origin: CGPoint(), size: buttonSize)
|
||||
self.selectedBackgroundNode.frame = CGRect(origin: CGPoint(), size: buttonSize)
|
||||
self.iconNode.frame = CGRect(origin: CGPoint(), size: buttonSize)
|
||||
self.backgroundNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: buttonSize)
|
||||
self.selectedBackgroundNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: buttonSize)
|
||||
self.iconNode.frame = CGRect(origin: CGPoint(x: 12.0, y: 12.0), size: buttonSize)
|
||||
|
||||
if self.appeared {
|
||||
if !self.buttonNode.isSelected && isHidden {
|
||||
@ -1174,7 +1174,7 @@ private class ImageRecognitionOverlayContentNode: GalleryOverlayContentNode {
|
||||
}
|
||||
}
|
||||
|
||||
transition.updateFrame(node: self.buttonNode, frame: CGRect(x: size.width - rightInset - buttonSize.width - 12.0, y: size.height - bottomInset - buttonSize.height - 12.0, width: buttonSize.width, height: buttonSize.height))
|
||||
transition.updateFrame(node: self.buttonNode, frame: CGRect(x: size.width - rightInset - buttonSize.width - 24.0, y: size.height - bottomInset - buttonSize.height - 24.0, width: buttonSize.width + 24.0, height: buttonSize.height + 24.0))
|
||||
}
|
||||
|
||||
override func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
|
||||
|
@ -82,7 +82,7 @@ private enum Knob {
|
||||
case right
|
||||
}
|
||||
|
||||
private final class RecognizedTextSelectionGetureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private final class RecognizedTextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private var longTapTimer: Timer?
|
||||
private var movingKnob: (Knob, CGPoint, CGPoint)?
|
||||
private var currentLocation: CGPoint?
|
||||
@ -225,7 +225,7 @@ public final class RecognizedTextSelectionNode: ASDisplayNode {
|
||||
|
||||
public let highlightAreaNode: ASDisplayNode
|
||||
|
||||
private var recognizer: RecognizedTextSelectionGetureRecognizer?
|
||||
private var recognizer: RecognizedTextSelectionGestureRecognizer?
|
||||
|
||||
public init(size: CGSize, theme: RecognizedTextSelectionTheme, strings: PresentationStrings, recognitions: [RecognizedContent], updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, RecognizedTextSelectionAction) -> Void) {
|
||||
self.size = size
|
||||
@ -286,7 +286,7 @@ public final class RecognizedTextSelectionNode: ASDisplayNode {
|
||||
return self?.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
let recognizer = RecognizedTextSelectionGetureRecognizer(target: nil, action: nil)
|
||||
let recognizer = RecognizedTextSelectionGestureRecognizer(target: nil, action: nil)
|
||||
recognizer.knobAtPoint = { [weak self] point in
|
||||
return self?.knobAtPoint(point)
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ private enum Knob {
|
||||
case right
|
||||
}
|
||||
|
||||
private final class InstantPageTextSelectionGetureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private final class InstantPageTextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private var longTapTimer: Timer?
|
||||
private var movingKnob: (Knob, CGPoint, CGPoint)?
|
||||
private var currentLocation: CGPoint?
|
||||
@ -220,7 +220,7 @@ final class InstantPageTextSelectionNode: ASDisplayNode {
|
||||
|
||||
public let highlightAreaNode: ASDisplayNode
|
||||
|
||||
private var recognizer: InstantPageTextSelectionGetureRecognizer?
|
||||
private var recognizer: InstantPageTextSelectionGestureRecognizer?
|
||||
private var displayLinkAnimator: DisplayLinkAnimator?
|
||||
|
||||
public init(theme: InstantPageTextSelectionTheme, strings: PresentationStrings, textItemAtLocation: @escaping (CGPoint) -> (InstantPageTextItem, CGPoint)?, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (String, InstantPageTextSelectionAction) -> Void) {
|
||||
@ -263,7 +263,7 @@ final class InstantPageTextSelectionNode: ASDisplayNode {
|
||||
return self?.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
let recognizer = InstantPageTextSelectionGetureRecognizer(target: nil, action: nil)
|
||||
let recognizer = InstantPageTextSelectionGestureRecognizer(target: nil, action: nil)
|
||||
recognizer.knobAtPoint = { [weak self] point in
|
||||
return self?.knobAtPoint(point)
|
||||
}
|
||||
|
@ -8,6 +8,7 @@
|
||||
@interface TGPhotoCaptionInputMixin : NSObject
|
||||
|
||||
@property (nonatomic, strong) id<TGPhotoPaintStickersContext> stickersContext;
|
||||
@property (nonatomic, readonly) UIView *backgroundView;
|
||||
@property (nonatomic, readonly) id<TGCaptionPanelView> inputPanel;
|
||||
@property (nonatomic, readonly) UIView *inputPanelView;
|
||||
@property (nonatomic, readonly) UIView *dismissView;
|
||||
|
@ -1090,6 +1090,7 @@
|
||||
_portraitToolbarView.alpha = alpha;
|
||||
_landscapeToolbarView.alpha = alpha;
|
||||
_captionMixin.inputPanelView.alpha = alpha;
|
||||
_captionMixin.backgroundView.alpha = alpha;
|
||||
} completion:^(BOOL finished)
|
||||
{
|
||||
if (finished)
|
||||
@ -1099,6 +1100,7 @@
|
||||
_portraitToolbarView.userInteractionEnabled = !hidden;
|
||||
_landscapeToolbarView.userInteractionEnabled = !hidden;
|
||||
_captionMixin.inputPanelView.userInteractionEnabled = !hidden;
|
||||
_captionMixin.backgroundView.userInteractionEnabled = !hidden;
|
||||
}
|
||||
}];
|
||||
|
||||
@ -1133,6 +1135,9 @@
|
||||
|
||||
_captionMixin.inputPanelView.alpha = alpha;
|
||||
_captionMixin.inputPanelView.userInteractionEnabled = !hidden;
|
||||
|
||||
_captionMixin.backgroundView.alpha = alpha;
|
||||
_captionMixin.backgroundView.userInteractionEnabled = !hidden;
|
||||
}
|
||||
|
||||
if (hidden)
|
||||
@ -1301,6 +1306,7 @@
|
||||
- (void)immediateEditorTransitionIn {
|
||||
[self setSelectionInterfaceHidden:true animated:false];
|
||||
_captionMixin.inputPanelView.alpha = 0.0f;
|
||||
_captionMixin.backgroundView.alpha = 0.0f;
|
||||
_portraitToolbarView.doneButton.alpha = 0.0f;
|
||||
_landscapeToolbarView.doneButton.alpha = 0.0f;
|
||||
|
||||
@ -1321,6 +1327,7 @@
|
||||
[UIView animateWithDuration:0.2 animations:^
|
||||
{
|
||||
_captionMixin.inputPanelView.alpha = 0.0f;
|
||||
_captionMixin.backgroundView.alpha = 0.0f;
|
||||
_portraitToolbarView.doneButton.alpha = 0.0f;
|
||||
_landscapeToolbarView.doneButton.alpha = 0.0f;
|
||||
}];
|
||||
@ -1333,6 +1340,7 @@
|
||||
[UIView animateWithDuration:0.3 animations:^
|
||||
{
|
||||
_captionMixin.inputPanelView.alpha = 1.0f;
|
||||
_captionMixin.backgroundView.alpha = 1.0f;
|
||||
_portraitToolbarView.doneButton.alpha = 1.0f;
|
||||
_landscapeToolbarView.doneButton.alpha = 1.0f;
|
||||
}];
|
||||
|
@ -13,7 +13,6 @@
|
||||
|
||||
@interface TGPhotoCaptionInputMixin ()
|
||||
{
|
||||
UIView *_backgroundView;
|
||||
TGObserverProxy *_keyboardWillChangeFrameProxy;
|
||||
bool _editing;
|
||||
|
||||
|
@ -122,11 +122,10 @@ private final class ActiveSessionsContextImpl {
|
||||
}
|
||||
|
||||
func updateSessionAcceptsSecretChats(_ session: RecentAccountSession, accepts: Bool) -> Signal<Never, UpdateSessionError> {
|
||||
let updatedSession = session.withUpdatedAcceptsSecretChats(accepts)
|
||||
|
||||
var mergedSessions = self._state.sessions
|
||||
for i in 0 ..< mergedSessions.count {
|
||||
if mergedSessions[i].hash == updatedSession.hash {
|
||||
if mergedSessions[i].hash == session.hash {
|
||||
let updatedSession = mergedSessions[i].withUpdatedAcceptsSecretChats(accepts)
|
||||
mergedSessions.remove(at: i)
|
||||
mergedSessions.insert(updatedSession, at: i)
|
||||
break
|
||||
@ -145,11 +144,10 @@ private final class ActiveSessionsContextImpl {
|
||||
}
|
||||
|
||||
func updateSessionAcceptsIncomingCalls(_ session: RecentAccountSession, accepts: Bool) -> Signal<Never, UpdateSessionError> {
|
||||
let updatedSession = session.withUpdatedAcceptsIncomingCalls(accepts)
|
||||
|
||||
var mergedSessions = self._state.sessions
|
||||
for i in 0 ..< mergedSessions.count {
|
||||
if mergedSessions[i].hash == updatedSession.hash {
|
||||
if mergedSessions[i].hash == session.hash {
|
||||
let updatedSession = mergedSessions[i].withUpdatedAcceptsIncomingCalls(accepts)
|
||||
mergedSessions.remove(at: i)
|
||||
mergedSessions.insert(updatedSession, at: i)
|
||||
break
|
||||
|
@ -7423,7 +7423,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
||||
if let strongSelf = self, let inputMode = inputMode, let selectionRange = selectionRange {
|
||||
if let link = link {
|
||||
strongSelf.interfaceInteraction?.updateTextInputStateAndMode { current, inputMode in
|
||||
return (chatTextInputAddLinkAttribute(current, url: link), inputMode)
|
||||
return (chatTextInputAddLinkAttribute(current, selectionRange: selectionRange, url: link), inputMode)
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -9666,6 +9666,7 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
||||
var presentationInterfaceState = ChatPresentationInterfaceState(chatWallpaper: .builtin(WallpaperSettings()), theme: presentationData.theme, strings: presentationData.strings, dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, limitsConfiguration: self.context.currentLimitsConfiguration.with { $0 }, fontSize: presentationData.chatFontSize, bubbleCorners: presentationData.chatBubbleCorners, accountPeerId: self.context.account.peerId, mode: .standard(previewing: false), chatLocation: .peer(PeerId(0)), subject: nil, peerNearbyData: nil, greetingData: nil, pendingUnpinnedAllMessages: false, activeGroupCallInfo: nil, hasActiveGroupCall: false, importState: nil)
|
||||
|
||||
var updateChatPresentationInterfaceStateImpl: (((ChatPresentationInterfaceState) -> ChatPresentationInterfaceState) -> Void)?
|
||||
var ensureFocusedImpl: (() -> Void)?
|
||||
|
||||
let interfaceInteraction = ChatPanelInterfaceInteraction(updateTextInputStateAndMode: { f in
|
||||
updateChatPresentationInterfaceStateImpl?({
|
||||
@ -9704,10 +9705,11 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
||||
if let link = link {
|
||||
updateChatPresentationInterfaceStateImpl?({
|
||||
return $0.updatedInterfaceState({
|
||||
$0.withUpdatedComposeInputState(chatTextInputAddLinkAttribute($0.composeInputState, url: link))
|
||||
$0.withUpdatedEffectiveInputState(chatTextInputAddLinkAttribute($0.effectiveInputState, selectionRange: selectionRange, url: link))
|
||||
})
|
||||
})
|
||||
}
|
||||
ensureFocusedImpl?()
|
||||
updateChatPresentationInterfaceStateImpl?({
|
||||
return $0.updatedInputMode({ _ in return inputMode }).updatedInterfaceState({
|
||||
$0.withUpdatedEffectiveInputState(ChatTextInputState(inputText: $0.effectiveInputState.inputText, selectionRange: selectionRange.endIndex ..< selectionRange.endIndex))
|
||||
@ -9736,6 +9738,10 @@ public final class ChatControllerImpl: TelegramBaseController, ChatController, G
|
||||
}
|
||||
}
|
||||
|
||||
ensureFocusedImpl = { [weak inputPanelNode] in
|
||||
inputPanelNode?.ensureFocused()
|
||||
}
|
||||
|
||||
return inputPanelNode
|
||||
}
|
||||
|
||||
|
@ -215,6 +215,7 @@ class ChatMessageReplyInfoNode: ASDisplayNode {
|
||||
imageNode.removeFromSupernode()
|
||||
node.imageNode = nil
|
||||
}
|
||||
node.imageNode?.captureProtected = message.isCopyProtected()
|
||||
|
||||
titleNode.frame = CGRect(origin: CGPoint(x: leftInset - textInsets.left, y: spacing - textInsets.top), size: titleLayout.size)
|
||||
textNode.frame = CGRect(origin: CGPoint(x: leftInset - textInsets.left, y: titleNode.frame.maxY - textInsets.bottom + spacing - textInsets.top), size: textLayout.size)
|
||||
|
@ -51,9 +51,9 @@ func chatTextInputClearFormattingAttributes(_ state: ChatTextInputState) -> Chat
|
||||
}
|
||||
}
|
||||
|
||||
func chatTextInputAddLinkAttribute(_ state: ChatTextInputState, url: String) -> ChatTextInputState {
|
||||
if !state.selectionRange.isEmpty {
|
||||
let nsRange = NSRange(location: state.selectionRange.lowerBound, length: state.selectionRange.count)
|
||||
func chatTextInputAddLinkAttribute(_ state: ChatTextInputState, selectionRange: Range<Int>, url: String) -> ChatTextInputState {
|
||||
if !selectionRange.isEmpty {
|
||||
let nsRange = NSRange(location: selectionRange.lowerBound, length: selectionRange.count)
|
||||
var linkRange = nsRange
|
||||
var attributesToRemove: [(NSAttributedString.Key, NSRange)] = []
|
||||
state.inputText.enumerateAttributes(in: nsRange, options: .longestEffectiveRangeNotRequired) { attributes, range, stop in
|
||||
@ -72,7 +72,7 @@ func chatTextInputAddLinkAttribute(_ state: ChatTextInputState, url: String) ->
|
||||
result.removeAttribute(attribute, range: range)
|
||||
}
|
||||
result.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: nsRange)
|
||||
return ChatTextInputState(inputText: result, selectionRange: state.selectionRange)
|
||||
return ChatTextInputState(inputText: result, selectionRange: selectionRange)
|
||||
} else {
|
||||
return state
|
||||
}
|
||||
|
@ -2530,8 +2530,9 @@ final class PeerInfoHeaderNode: ASDisplayNode {
|
||||
}
|
||||
|
||||
let buttonSpacing: CGFloat = 8.0
|
||||
var buttonRightOrigin = CGPoint(x: width - containerInset, y: maxY + 25.0 - navigationHeight - UIScreenPixel)
|
||||
let buttonWidth = (width - containerInset * 2.0 + buttonSpacing) / CGFloat(buttonKeys.count) - buttonSpacing
|
||||
let buttonSideInset = max(16.0, containerInset)
|
||||
var buttonRightOrigin = CGPoint(x: width - buttonSideInset, y: maxY + 25.0 - navigationHeight - UIScreenPixel)
|
||||
let buttonWidth = (width - buttonSideInset * 2.0 + buttonSpacing) / CGFloat(buttonKeys.count) - buttonSpacing
|
||||
|
||||
let apparentButtonSize = CGSize(width: buttonWidth, height: 58.0)
|
||||
let buttonsAlpha: CGFloat = 1.0
|
||||
|
@ -176,7 +176,7 @@ final class PeerSelectionControllerNode: ASDisplayNode {
|
||||
if !hasChatListSelector && hasContactSelector {
|
||||
self.indexChanged(1)
|
||||
}
|
||||
|
||||
|
||||
self.interfaceInteraction = ChatPanelInterfaceInteraction(setupReplyMessage: { _, _ in
|
||||
}, setupEditMessage: { _, _ in
|
||||
}, beginMessageSelection: { _, _ in
|
||||
@ -271,7 +271,42 @@ final class PeerSelectionControllerNode: ASDisplayNode {
|
||||
}, requestStopPollInMessage: { _ in
|
||||
}, updateInputLanguage: { _ in
|
||||
}, unarchiveChat: {
|
||||
}, openLinkEditing: {
|
||||
}, openLinkEditing: { [weak self] in
|
||||
if let strongSelf = self {
|
||||
var selectionRange: Range<Int>?
|
||||
var text: String?
|
||||
var inputMode: ChatInputMode?
|
||||
|
||||
strongSelf.updateChatPresentationInterfaceState(animated: true, { state in
|
||||
selectionRange = state.interfaceState.effectiveInputState.selectionRange
|
||||
if let selectionRange = selectionRange {
|
||||
text = state.interfaceState.effectiveInputState.inputText.attributedSubstring(from: NSRange(location: selectionRange.startIndex, length: selectionRange.count)).string
|
||||
}
|
||||
inputMode = state.inputMode
|
||||
return state
|
||||
})
|
||||
|
||||
let controller = chatTextLinkEditController(sharedContext: strongSelf.context.sharedContext, updatedPresentationData: (presentationData, .never()), account: strongSelf.context.account, text: text ?? "", link: nil, apply: { [weak self] link in
|
||||
if let strongSelf = self, let inputMode = inputMode, let selectionRange = selectionRange {
|
||||
if let link = link {
|
||||
strongSelf.updateChatPresentationInterfaceState(animated: true, { state in
|
||||
return state.updatedInterfaceState({
|
||||
$0.withUpdatedEffectiveInputState(chatTextInputAddLinkAttribute($0.effectiveInputState, selectionRange: selectionRange, url: link))
|
||||
})
|
||||
})
|
||||
}
|
||||
if let textInputPanelNode = strongSelf.textInputPanelNode {
|
||||
textInputPanelNode.ensureFocused()
|
||||
}
|
||||
strongSelf.updateChatPresentationInterfaceState(animated: true, { state in
|
||||
return state.updatedInputMode({ _ in return inputMode }).updatedInterfaceState({
|
||||
$0.withUpdatedEffectiveInputState(ChatTextInputState(inputText: $0.effectiveInputState.inputText, selectionRange: selectionRange.endIndex ..< selectionRange.endIndex))
|
||||
})
|
||||
})
|
||||
}
|
||||
})
|
||||
strongSelf.present(controller, nil)
|
||||
}
|
||||
}, reportPeerIrrelevantGeoLocation: {
|
||||
}, displaySlowmodeTooltip: { _, _ in
|
||||
}, displaySendMessageOptions: { [weak self] node, gesture in
|
||||
|
@ -322,7 +322,7 @@ class PeerSelectionTextInputPanelNode: ChatInputPanelNode, TGCaptionPanelView, A
|
||||
}
|
||||
|
||||
public func dismissInput() {
|
||||
self.view.window?.endEditing(true)
|
||||
self.ensureUnfocused()
|
||||
}
|
||||
|
||||
public func baseHeight() -> CGFloat {
|
||||
@ -614,7 +614,7 @@ class PeerSelectionTextInputPanelNode: ChatInputPanelNode, TGCaptionPanelView, A
|
||||
}
|
||||
|
||||
if self.isCaption {
|
||||
if !(self.textInputNode?.isFirstResponder() ?? false) {
|
||||
if !self.isFocused {
|
||||
panelHeight = minimalHeight
|
||||
|
||||
transition.updateAlpha(node: self.oneLineNode, alpha: inputHasText ? 1.0 : 0.0)
|
||||
@ -942,8 +942,12 @@ class PeerSelectionTextInputPanelNode: ChatInputPanelNode, TGCaptionPanelView, A
|
||||
}
|
||||
}
|
||||
|
||||
private var imitateFocus = false
|
||||
@objc func formatAttributesLink(_ sender: Any) {
|
||||
self.inputMenu.back()
|
||||
if self.isCaption {
|
||||
self.imitateFocus = true
|
||||
}
|
||||
self.interfaceInteraction?.openLinkEditing()
|
||||
}
|
||||
|
||||
@ -1061,6 +1065,9 @@ class PeerSelectionTextInputPanelNode: ChatInputPanelNode, TGCaptionPanelView, A
|
||||
}
|
||||
|
||||
var isFocused: Bool {
|
||||
if self.imitateFocus {
|
||||
return true
|
||||
}
|
||||
return self.textInputNode?.isFirstResponder() ?? false
|
||||
}
|
||||
|
||||
@ -1069,6 +1076,8 @@ class PeerSelectionTextInputPanelNode: ChatInputPanelNode, TGCaptionPanelView, A
|
||||
}
|
||||
|
||||
func ensureFocused() {
|
||||
self.imitateFocus = false
|
||||
|
||||
if self.textInputNode == nil {
|
||||
self.loadTextInputNode()
|
||||
}
|
||||
|
@ -67,7 +67,7 @@ private enum Knob {
|
||||
case right
|
||||
}
|
||||
|
||||
private final class TextSelectionGetureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private final class TextSelectionGestureRecognizer: UIGestureRecognizer, UIGestureRecognizerDelegate {
|
||||
private var longTapTimer: Timer?
|
||||
private var movingKnob: (Knob, CGPoint, CGPoint)?
|
||||
private var currentLocation: CGPoint?
|
||||
@ -207,7 +207,7 @@ public final class TextSelectionNode: ASDisplayNode {
|
||||
|
||||
public let highlightAreaNode: ASDisplayNode
|
||||
|
||||
private var recognizer: TextSelectionGetureRecognizer?
|
||||
private var recognizer: TextSelectionGestureRecognizer?
|
||||
private var displayLinkAnimator: DisplayLinkAnimator?
|
||||
|
||||
public init(theme: TextSelectionTheme, strings: PresentationStrings, textNode: TextNode, updateIsActive: @escaping (Bool) -> Void, present: @escaping (ViewController, Any?) -> Void, rootNode: ASDisplayNode, performAction: @escaping (NSAttributedString, TextSelectionAction) -> Void) {
|
||||
@ -250,7 +250,7 @@ public final class TextSelectionNode: ASDisplayNode {
|
||||
return self?.hitTest(point, with: event)
|
||||
}
|
||||
|
||||
let recognizer = TextSelectionGetureRecognizer(target: nil, action: nil)
|
||||
let recognizer = TextSelectionGestureRecognizer(target: nil, action: nil)
|
||||
recognizer.knobAtPoint = { [weak self] point in
|
||||
return self?.knobAtPoint(point)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user