[COMPONENTS][Conversation] Local Conversations done
[COMPONENTS][Posting] Call Conversation::assignLocalConversation upon creating a new note By using the AddExtraArgsToNoteContent event upon posting a Note, an extra argument ('reply_to') is added before storing the aforementioned Note. When storeLocalNote eventually creates the Note, the corresponding Conversation is assigned.
This commit is contained in:
parent
3ca7a35158
commit
48b2c8c04e
|
@ -26,46 +26,31 @@ declare(strict_types = 1);
|
|||
|
||||
namespace Component\Conversation\Controller;
|
||||
|
||||
use _PHPStan_76800bfb5\Nette\NotImplementedException;
|
||||
use App\Core\Controller\FeedController;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\ServerException;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\InvalidFormException;
|
||||
use App\Util\Exception\NoSuchNoteException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Form\FormFields;
|
||||
use Component\Posting\Posting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends FeedController
|
||||
{
|
||||
// if note is root -> just link
|
||||
// if note is a reply -> link from above plus anchor
|
||||
public function ConversationShow(Request $request)
|
||||
/**
|
||||
* Render conversation page
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function showConversation(Request $request, int $conversation_id)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
$actor_id = Common::ensureLoggedIn()->getId();
|
||||
$notes = DB::dql('select n from App\Entity\Note n '
|
||||
. 'where n.reply_to is not null and n.actor_id = :id '
|
||||
. 'order by n.created DESC', ['id' => $actor_id], );
|
||||
// TODO:
|
||||
// if note is root -> just link
|
||||
// if note is a reply -> link from above plus anchor
|
||||
|
||||
$notes = DB::dql('select n from App\Entity\Note n '
|
||||
. 'on n.conversation_id = :id '
|
||||
. 'order by n.created DESC', ['id' => $conversation_id], );
|
||||
return [
|
||||
'_template' => 'feeds/feed.html.twig',
|
||||
'notes' => $notes,
|
||||
'_template' => 'feeds/feed.html.twig',
|
||||
'notes' => $notes,
|
||||
'should_format' => false,
|
||||
'page_title' => 'Replies feed',
|
||||
'page_title' => 'Conversation',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,25 +28,12 @@ namespace Component\Conversation\Controller;
|
|||
|
||||
use App\Core\Controller\FeedController;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Form;
|
||||
use function App\Core\I18n\_m;
|
||||
use App\Core\Log;
|
||||
use App\Core\Router\Router;
|
||||
use App\Entity\Actor;
|
||||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
use App\Util\Exception\InvalidFormException;
|
||||
use App\Util\Exception\NoLoggedInUser;
|
||||
use App\Util\Exception\NoSuchNoteException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\FormFields;
|
||||
use Component\Posting\Posting;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Reply extends FeedController
|
||||
|
@ -55,110 +42,45 @@ class Reply extends FeedController
|
|||
* Controller for the note reply non-JS page
|
||||
*
|
||||
* @throws ClientException
|
||||
* @throws DuplicateFoundException
|
||||
* @throws InvalidFormException
|
||||
* @throws NoLoggedInUser
|
||||
* @throws NoSuchNoteException
|
||||
* @throws RedirectException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function replyAddNote(Request $request, int $id)
|
||||
public function addReply(Request $request, int $note_id, int $actor_id)
|
||||
{
|
||||
$user = Common::ensureLoggedIn();
|
||||
$actor_id = $user->getId();
|
||||
$user = Common::ensureLoggedIn();
|
||||
|
||||
$note = Note::getByPK($id);
|
||||
$note = Note::getByPK($note_id);
|
||||
if (\is_null($note) || !$note->isVisibleTo($user)) {
|
||||
throw new NoSuchNoteException();
|
||||
}
|
||||
|
||||
/*
|
||||
* TODO shouldn't this be the posting form?
|
||||
* Posting needs to be improved to do that. Currently, if it was used here,
|
||||
* there are only slow ways to retrieve the resulting note.
|
||||
* Not only is it part of a right panel event, there's an immediate redirect exception
|
||||
* after submitting it.
|
||||
* That event needs to be extended to allow this component to automagically fill the To: field and get the
|
||||
* resulting note
|
||||
*/
|
||||
$form = Form::create([
|
||||
['content', TextareaType::class, ['label' => _m('Reply'), 'label_attr' => ['class' => 'section-form-label'], 'help' => _m('Please input your reply.')]],
|
||||
FormFields::language(
|
||||
$user->getActor(),
|
||||
context_actor: $note->getActor(),
|
||||
label: _m('Note language'),
|
||||
),
|
||||
['attachments', FileType::class, ['label' => ' ', 'multiple' => true, 'required' => false]],
|
||||
['replyform', SubmitType::class, ['label' => _m('Submit')]],
|
||||
]);
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted()) {
|
||||
$data = $form->getData();
|
||||
if ($form->isValid()) {
|
||||
// Create a new note with the same content as the original
|
||||
$reply = Posting::storeLocalNote(
|
||||
actor: Actor::getByPK($actor_id),
|
||||
content: $data['content'],
|
||||
content_type: 'text/plain', // TODO
|
||||
language: $data['language'],
|
||||
attachments: $data['attachments'],
|
||||
);
|
||||
|
||||
// Update DB
|
||||
DB::persist($reply);
|
||||
DB::flush();
|
||||
|
||||
// Find the id of the note we just created
|
||||
$reply_id = $reply->getId();
|
||||
$parent_id = $note->getId();
|
||||
$resulting_note = Note::getByPK($reply_id);
|
||||
$resulting_note->setReplyTo($parent_id);
|
||||
|
||||
// Update DB one last time
|
||||
DB::merge($note);
|
||||
DB::flush();
|
||||
|
||||
// Redirect user to where they came from
|
||||
// Prevent open redirect
|
||||
if (!\is_null($from = $this->string('from'))) {
|
||||
if (Router::isAbsolute($from)) {
|
||||
Log::warning("Actor {$actor_id} attempted to reply to a note and then get redirected to another host, or the URL was invalid ({$from})");
|
||||
throw new ClientException(_m('Can not redirect to outside the website from here'), 400); // 400 Bad request (deceptive)
|
||||
} else {
|
||||
// TODO anchor on element id
|
||||
throw new RedirectException($from);
|
||||
}
|
||||
} else {
|
||||
// If we don't have a URL to return to, go to the instance root
|
||||
throw new RedirectException('root');
|
||||
}
|
||||
} else {
|
||||
throw new InvalidFormException();
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'_template' => 'reply/add_reply.html.twig',
|
||||
'note' => $note,
|
||||
'add_reply' => $form->createView(),
|
||||
];
|
||||
}
|
||||
|
||||
public function replies(Request $request)
|
||||
/**
|
||||
* Render actor replies page
|
||||
*
|
||||
* @throws NoLoggedInUser
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function showReplies(Request $request)
|
||||
{
|
||||
$actor_id = Common::ensureLoggedIn()->getId();
|
||||
$notes = DB::dql('select n from App\Entity\Note n '
|
||||
. 'where n.reply_to is not null and n.actor_id = :id '
|
||||
. 'order by n.created DESC', ['id' => $actor_id], );
|
||||
. 'where n.reply_to is not null and n.actor_id = :id '
|
||||
. 'order by n.created DESC', ['id' => $actor_id], );
|
||||
return [
|
||||
'_template' => 'feeds/feed.html.twig',
|
||||
'notes' => $notes,
|
||||
'_template' => 'feeds/feed.html.twig',
|
||||
'notes' => $notes,
|
||||
'should_format' => false,
|
||||
'page_title' => 'Replies feed',
|
||||
'page_title' => 'Replies feed',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,10 +38,49 @@ use App\Util\Exception\ServerException;
|
|||
use App\Util\Formatting;
|
||||
use App\Util\Nickname;
|
||||
use Component\Conversation\Controller\Reply as ReplyController;
|
||||
use Component\Conversation\Entity\Conversation as ConversationEntity;
|
||||
use const SORT_REGULAR;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
|
||||
class Conversation extends Component
|
||||
{
|
||||
/**
|
||||
* **Assigns** the given local Note it's corresponding **Conversation**
|
||||
*
|
||||
* **If a _$parent_id_ is not given**, then the Actor is not attempting a reply,
|
||||
* therefore, we can assume (for now) that we need to create a new Conversation and assign it
|
||||
* to the newly created Note (please look at Component\Posting::storeLocalNote, where this function is used)
|
||||
*
|
||||
* **On the other hand**, given a _$parent_id_, the Actor is attempting to post a reply. Meaning that,
|
||||
* this Note conversation_id should be same as the parent Note
|
||||
*/
|
||||
public static function assignLocalConversation(Note $current_note, ?int $parent_id): void
|
||||
{
|
||||
if (!$parent_id) {
|
||||
// If none found, we don't know yet if it is a reply or root
|
||||
// Let's assume for now that it's a new conversation and deal with stitching later
|
||||
$conversation = ConversationEntity::create(['initial_note_id' => $current_note->getId()]);
|
||||
|
||||
// We need the Conversation id itself, so a persist is in order
|
||||
DB::persist($conversation);
|
||||
|
||||
// Set the Uri and current_note's own conversation_id
|
||||
$conversation->setUri(Router::url('conversation', ['conversation_id' => $conversation->getId()], Router::ABSOLUTE_URL));
|
||||
$current_note->setConversationId($conversation->getId());
|
||||
} else {
|
||||
// It's a reply for sure
|
||||
// Set reply_to property in newly created Note to parent's id
|
||||
$current_note->setReplyTo($parent_id);
|
||||
|
||||
// Parent will have a conversation of its own, the reply should have the same one
|
||||
$parent_note = Note::getById($parent_id);
|
||||
$current_note->setConversationId($parent_note->getConversationId());
|
||||
}
|
||||
|
||||
DB::merge($current_note);
|
||||
DB::flush();
|
||||
}
|
||||
|
||||
/**
|
||||
* HTML rendering event that adds a reply link as a note
|
||||
* action, if a user is logged in
|
||||
|
@ -52,14 +91,15 @@ class Conversation extends Component
|
|||
return Event::next;
|
||||
}
|
||||
|
||||
// Generating URL for repeat action route
|
||||
$args = ['id' => $note->getId()];
|
||||
// Generating URL for reply action route
|
||||
$args = ['note_id' => $note->getId(), 'actor_id' => $note->getActor()->getId()];
|
||||
$type = Router::ABSOLUTE_PATH;
|
||||
$reply_action_url = Router::url('reply_add', $args, $type);
|
||||
|
||||
$query_string = $request->getQueryString();
|
||||
// Concatenating get parameter to redirect the user to where he came from
|
||||
$reply_action_url .= !\is_null($query_string) ? '?from=' . mb_substr($query_string, 2) : '';
|
||||
$reply_action_url .= !\is_null($query_string) ? '?from=' : '&from=';
|
||||
$reply_action_url .= mb_substr($query_string, 2);
|
||||
|
||||
$reply_action = [
|
||||
'url' => $reply_action_url,
|
||||
|
@ -72,6 +112,14 @@ class Conversation extends Component
|
|||
return Event::next;
|
||||
}
|
||||
|
||||
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args): bool
|
||||
{
|
||||
// If Actor is adding a reply, get parent's Note id
|
||||
// Else it's null
|
||||
$extra_args['reply_to'] = $request->get('_route') === 'reply_add' ? (int) $request->get('note_id') : null;
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append on note information about user actions
|
||||
*/
|
||||
|
@ -95,7 +143,7 @@ class Conversation extends Component
|
|||
}
|
||||
|
||||
// Filter out multiple replies from the same actor
|
||||
$reply_actor = array_unique($reply_actor, \SORT_REGULAR);
|
||||
$reply_actor = array_unique($reply_actor, SORT_REGULAR);
|
||||
|
||||
// Add to complementary info
|
||||
foreach ($reply_actor as $actor) {
|
||||
|
@ -125,27 +173,16 @@ class Conversation extends Component
|
|||
return Event::next;
|
||||
}
|
||||
|
||||
public function onProcessNoteContent(Note $note, string $content): bool
|
||||
public function onAddRoute(RouteLoader $r): bool
|
||||
{
|
||||
// If the source lacks capability of sending the "reply_to"
|
||||
// metadata, let's try to find an inline reply_to-reference.
|
||||
// TODO: preg match any reply_to reference and handle reply to funky business (see Link component)
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function onAddRoute(RouteLoader $r)
|
||||
{
|
||||
$r->connect('reply_add', '/object/note/{id<\d+>}/reply', [ReplyController::class, 'replyAddNote']);
|
||||
$r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [ReplyController::class, 'replies']);
|
||||
$r->connect('conversation', '/conversation/{id<\d+>}', [ReplyController::class, 'conversation']);
|
||||
$r->connect('reply_add', '/object/note/new?to={actor_id<\d+>}&reply_to={note_id<\d+>}', [ReplyController::class, 'addReply']);
|
||||
$r->connect('replies', '/@{nickname<' . Nickname::DISPLAY_FMT . '>}/replies', [ReplyController::class, 'showReplies']);
|
||||
$r->connect('conversation', '/conversation/{conversation_id<\d+>}', [Controller\Conversation::class, 'showConversation']);
|
||||
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering)
|
||||
public function onCreateDefaultFeeds(int $actor_id, LocalUser $user, int &$ordering): bool
|
||||
{
|
||||
DB::persist(Feed::create([
|
||||
'actor_id' => $actor_id,
|
||||
|
|
|
@ -23,9 +23,7 @@ declare(strict_types = 1);
|
|||
|
||||
namespace Component\Conversation\Entity;
|
||||
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Entity\Note;
|
||||
|
||||
/**
|
||||
* Entity class for Conversations
|
||||
|
@ -83,7 +81,6 @@ class Conversation extends Entity
|
|||
return $this->initial_note_id;
|
||||
}
|
||||
|
||||
|
||||
// @codeCoverageIgnoreEnd
|
||||
// }}} Autocode
|
||||
|
||||
|
@ -92,11 +89,11 @@ class Conversation extends Entity
|
|||
return [
|
||||
'name' => 'conversation',
|
||||
'fields' => [
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'Serial identifier, since any additional meaning would require updating its value for every reply upon receiving a new aparent root'],
|
||||
'uri' => ['type' => 'varchar', 'not null' => true, 'length' => 191, 'description' => 'URI of the conversation'],
|
||||
'initial_note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Initial note seen on this host for this conversation'],
|
||||
'id' => ['type' => 'serial', 'not null' => true, 'description' => 'Serial identifier, since any additional meaning would require updating its value for every reply upon receiving a new aparent root'],
|
||||
'uri' => ['type' => 'varchar', 'not null' => true, 'length' => 191, 'description' => 'URI of the conversation'],
|
||||
'initial_note_id' => ['type' => 'int', 'foreign key' => true, 'target' => 'Note.id', 'multiplicity' => 'one to one', 'not null' => true, 'description' => 'Initial note seen on this host for this conversation'],
|
||||
],
|
||||
'primary key' => ['id'],
|
||||
'primary key' => ['id'],
|
||||
'unique keys' => [
|
||||
'conversation_uri_uniq' => ['uri'],
|
||||
],
|
||||
|
|
|
@ -13,7 +13,6 @@
|
|||
<div class="page">
|
||||
<div class="main">
|
||||
{{ noteView.macro_note_minimal(note) }}
|
||||
{{ form(add_reply) }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock body %}
|
||||
|
|
|
@ -25,6 +25,7 @@ namespace Component\Posting;
|
|||
|
||||
use App\Core\Cache;
|
||||
use App\Core\DB\DB;
|
||||
use App\Core\Entity;
|
||||
use App\Core\Event;
|
||||
use App\Core\Form;
|
||||
use App\Core\GSFile;
|
||||
|
@ -39,6 +40,7 @@ use App\Entity\Language;
|
|||
use App\Entity\Note;
|
||||
use App\Util\Common;
|
||||
use App\Util\Exception\ClientException;
|
||||
use App\Util\Exception\DuplicateFoundException;
|
||||
use App\Util\Exception\RedirectException;
|
||||
use App\Util\Exception\ServerException;
|
||||
use App\Util\Form\FormFields;
|
||||
|
@ -46,6 +48,7 @@ use App\Util\Formatting;
|
|||
use Component\Attachment\Entity\ActorToAttachment;
|
||||
use Component\Attachment\Entity\Attachment;
|
||||
use Component\Attachment\Entity\AttachmentToNote;
|
||||
use Component\Conversation\Conversation;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\FileType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
|
@ -67,7 +70,7 @@ class Posting extends Component
|
|||
*/
|
||||
public function onAppendRightPostingBlock(Request $request, array &$res): bool
|
||||
{
|
||||
if (($user = Common::user()) === null) {
|
||||
if (\is_null($user = Common::user())) {
|
||||
return Event::next;
|
||||
}
|
||||
|
||||
|
@ -95,8 +98,22 @@ class Posting extends Component
|
|||
];
|
||||
Event::handle('PostingAvailableContentTypes', [&$available_content_types]);
|
||||
|
||||
$context_actor = null; // This is where we'd plug in the group in which the actor is posting, or whom they're replying to
|
||||
$form_params = [
|
||||
// TODO: this needs work
|
||||
// This is where we'd plug in the group in which the actor is posting, or whom they're replying to
|
||||
// store local note needs to know what conversation it is
|
||||
// Conversation adds the respective query string on route url, for groups it should be handled by an event
|
||||
$to_query = $request->get('actor_id');
|
||||
$context_actor = null;
|
||||
|
||||
// Actor is posting in a group?
|
||||
if (!\is_null($to_query)) {
|
||||
// Getting the actor itself
|
||||
$context_actor = Actor::getById((int) $to_query);
|
||||
// Adding it to the to_tags array TODO: this is wrong
|
||||
$to_tags[] = $context_actor->getNickname();
|
||||
}
|
||||
|
||||
$form_params = [
|
||||
['to', ChoiceType::class, ['label' => _m('To:'), 'multiple' => false, 'expanded' => false, 'choices' => $to_tags]],
|
||||
['visibility', ChoiceType::class, ['label' => _m('Visibility:'), 'multiple' => false, 'expanded' => false, 'data' => 'public', 'choices' => [_m('Public') => 'public', _m('Instance') => 'instance', _m('Private') => 'private']]],
|
||||
['content', TextareaType::class, ['label' => _m('Content:'), 'data' => $initial_content, 'attr' => ['placeholder' => _m($placeholder)], 'constraints' => [new Length(['max' => Common::config('site', 'text_limit')])]]],
|
||||
|
@ -131,7 +148,7 @@ class Posting extends Component
|
|||
|
||||
$content_type = $data['content_type'] ?? $available_content_types[array_key_first($available_content_types)];
|
||||
$extra_args = [];
|
||||
Event::handle('PostingHandleForm', [$request, $actor, $data, &$extra_args, $form_params, $form]);
|
||||
Event::handle('AddExtraArgsToNoteContent', [$request, $actor, $data, &$extra_args, $form_params, $form]);
|
||||
|
||||
self::storeLocalNote(
|
||||
$user->getActor(),
|
||||
|
@ -141,6 +158,7 @@ class Posting extends Component
|
|||
$data['attachments'],
|
||||
process_note_content_extra_args: $extra_args,
|
||||
);
|
||||
|
||||
throw new RedirectException();
|
||||
}
|
||||
} catch (FormSizeFileException $sizeFileException) {
|
||||
|
@ -162,11 +180,11 @@ class Posting extends Component
|
|||
* @param array $processed_attachments Array of [Attachment, Attachment's name] to be associated to this $actor and Note
|
||||
* @param array $process_note_content_extra_args Extra arguments for the event ProcessNoteContent
|
||||
*
|
||||
* @throws \App\Util\Exception\DuplicateFoundException
|
||||
* @throws ClientException
|
||||
* @throws DuplicateFoundException
|
||||
* @throws ServerException
|
||||
*
|
||||
* @return \App\Core\Entity|mixed
|
||||
* @return Entity|mixed
|
||||
*/
|
||||
public static function storeLocalNote(
|
||||
Actor $actor,
|
||||
|
@ -212,6 +230,11 @@ class Posting extends Component
|
|||
Event::handle('ProcessNoteContent', [$note, $content, $content_type, $process_note_content_extra_args]);
|
||||
}
|
||||
|
||||
// Assign conversation to this note
|
||||
// AddExtraArgsToNoteContent already added the info we need
|
||||
$reply_to = $process_note_content_extra_args['reply_to'];
|
||||
Conversation::assignLocalConversation($note, $reply_to);
|
||||
|
||||
if ($processed_attachments !== []) {
|
||||
foreach ($processed_attachments as [$a, $fname]) {
|
||||
if (DB::count('actor_to_attachment', $args = ['attachment_id' => $a->getId(), 'actor_id' => $actor->getId()]) === 0) {
|
||||
|
|
|
@ -184,7 +184,7 @@ class Tag extends Component
|
|||
return Event::next;
|
||||
}
|
||||
|
||||
public function onPostingHandleForm(Request $request, Actor $actor, array $data, array &$extra_args)
|
||||
public function onAddExtraArgsToNoteContent(Request $request, Actor $actor, array $data, array &$extra_args)
|
||||
{
|
||||
if (!isset($data['tag_use_canonical'])) {
|
||||
throw new ClientException;
|
||||
|
|
Loading…
Reference in New Issue
Block a user