View source with formatted comments or as raw
    1/*  Part of SWISH
    2
    3    Author:        Jan Wielemaker
    4    E-mail:        J.Wielemaker@cs.vu.nl
    5    WWW:           http://www.swi-prolog.org
    6    Copyright (C): 2017, VU University Amsterdam
    7			 CWI Amsterdam
    8    All rights reserved.
    9
   10    Redistribution and use in source and binary forms, with or without
   11    modification, are permitted provided that the following conditions
   12    are met:
   13
   14    1. Redistributions of source code must retain the above copyright
   15       notice, this list of conditions and the following disclaimer.
   16
   17    2. Redistributions in binary form must reproduce the above copyright
   18       notice, this list of conditions and the following disclaimer in
   19       the documentation and/or other materials provided with the
   20       distribution.
   21
   22    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
   23    "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
   24    LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
   25    FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
   26    COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
   27    INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
   28    BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
   29    LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
   30    CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
   31    LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
   32    ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
   33    POSSIBILITY OF SUCH DAMAGE.
   34*/
   35
   36:- module(swish_notify,
   37          [ follow/3,                           % +DocID, +ProfileID, +Options
   38            notify/2                            % +DocID, +Action
   39          ]).   40:- use_module(library(settings)).   41:- use_module(library(persistency)).   42:- use_module(library(broadcast)).   43:- use_module(library(lists)).   44:- use_module(library(readutil)).   45:- use_module(library(debug)).   46:- use_module(library(error)).   47:- use_module(library(apply)).   48:- use_module(library(http/html_write)).   49:- use_module(library(http/http_session)).   50:- use_module(library(http/http_dispatch)).   51:- use_module(library(http/http_parameters)).   52:- use_module(library(http/http_json)).   53:- use_module(library(redis)).   54
   55:- use_module(library(user_profile)).   56
   57:- use_module(email).   58:- use_module('../bootstrap').   59:- use_module('../storage').   60:- use_module('../chat').   61:- use_module('../config').   62
   63/** <module> SWISH notifications
   64
   65This module keeps track of which users wish to track which notifications
   66and sending the notifications to the user.  If the target user is online
   67we will notify using an avatar.  Otherwise we send an email.
   68
   69A user has the following options to control notifications:
   70
   71  * Per (gitty) file
   72    - Notify update
   73    - Notify chat
   74  * By profile
   75    - Notify by E-mail: never/immediate/daily
   76*/
   77
   78:- setting(database, callable, data('notify.db'),
   79           "Database holding notifications").   80:- setting(queue, callable, data('notify-queue.db'),
   81           "File holding queued messages").   82:- setting(daily, compound, 04:00,
   83           "Time at which to send daily messages").   84
   85:- meta_predicate try(0).   86
   87		 /*******************************
   88		 *            DATABASE		*
   89		 *******************************/
   90
   91%!  redis_docid_key(+DocId, -Server, -Key) is semidet.
   92%!  redis_queue_key(Server, -Key) is semidet.
   93%
   94%   Get the Redis server and key for specific requests.  The Redis DB is
   95%   organised as follows:
   96%
   97%      - Prefix:notify:docid:Docid
   98%        A hash mapping profile ids onto a Prolog list of flags
   99%      - Prefix:notify:queue
  100%        A list of Prolog terms holding queued pending
  101%        email notifications.
  102
  103redis_docid_key(DocId, Server, Key) :-
  104    swish_config(redis, Server),
  105    swish_config(redis_prefix, Prefix),
  106    atomic_list_concat([Prefix, notify, docid, DocId], :, Key).
  107
  108redis_queue_key(Server, Key) :-
  109    swish_config(redis, Server),
  110    swish_config(redis_prefix, Prefix),
  111    atomic_list_concat([Prefix, notify, queue], :, Key).
  112
  113:- persistent
  114        follower(docid:atom,
  115                 profile:atom,
  116                 options:list(oneof([update,chat]))).  117
  118notify_open_db :-
  119    db_attached(_),
  120    !.
  121notify_open_db :-
  122    setting(database, Spec),
  123    absolute_file_name(Spec, Path, [access(write)]),
  124    db_attach(Path, [sync(close)]).
  125
  126%!  queue_event(+Profile, +DocID, +Action) is det.
  127%!  queue_event(+Profile, +DocID, +Action, +Status) is det.
  128%
  129%   Queue an email notification for  Profile,   described  by Action. We
  130%   simply append these events as Prolog terms to a file.
  131%
  132%   @arg Status is one of `new` or retry(Count,Status), where Count is
  133%   the number of left attempts and Status is the last status.
  134
  135queue_event(Profile, DocID, Action) :-
  136    queue_event(Profile, DocID, Action, new).
  137
  138queue_event(Profile, DocID, Action, Status) :-
  139    redis_queue_key(Server, Key),
  140    !,
  141    redis(Server, rpush(Key, notify(Profile, DocID, Action, Status) as prolog)).
  142queue_event(Profile, DocID, Action, Status) :-
  143    queue_file(Path),
  144    with_mutex(swish_notify,
  145               queue_event_sync(Path, Profile, DocID, Action, Status)),
  146    start_mail_scheduler.
  147
  148queue_event_sync(Path, Profile, DocID, Action, Status) :-
  149    setup_call_cleanup(
  150        open(Path, append, Out, [encoding(utf8)]),
  151        format(Out, '~q.~n', [notify(Profile, DocID, Action, Status)]),
  152        close(Out)).
  153
  154queue_file(Path) :-
  155    setting(queue, Spec),
  156    absolute_file_name(Spec, Path, [access(write)]).
  157
  158%!  send_queued_mails is det.
  159%
  160%   Send possible queued emails.
  161
  162send_queued_mails :-
  163    redis_queue_key(Server, Key),
  164    !,
  165    redis_send_queued_mails(Server, Key).
  166send_queued_mails :-
  167    queue_file(Path),
  168    exists_file(Path), !,
  169    atom_concat(Path, '.sending', Tmp),
  170    with_mutex(swish_notify, rename_file(Path, Tmp)),
  171    read_file_to_terms(Tmp, Terms, [encoding(utf8)]),
  172    forall(member(Term, Terms),
  173           send_queued(Term)),
  174    delete_file(Tmp).
  175send_queued_mails.
  176
  177
  178redis_send_queued_mails(Server, Key) :-
  179    (   redis(Server, lpop(Key), Term)
  180    ->  send_queued(Term),
  181        redis_send_queued_mails(Server, Key)
  182    ;   true
  183    ).
  184
  185send_queued(notify(Profile, DocID, Action, Status)) :-
  186    profile_property(Profile, email(Email)),
  187    profile_property(Profile, email_notifications(When)),
  188    When \== never, !,
  189    (   catch(send_notification_mail(Profile, DocID, Email, Action),
  190              Error, true)
  191    ->  (   var(Error)
  192        ->  true
  193        ;   update_status(Status, Error, NewStatus)
  194        ->  queue_event(Profile, Action, NewStatus)
  195        ;   true
  196        )
  197    ;   update_status(Status, failed, NewStatus)
  198    ->  queue_event(Profile, DocID, Action, NewStatus)
  199    ;   true
  200    ).
  201
  202update_status(new, Status, retry(3, Status)).
  203update_status(retry(Count0, _), Status, retry(Count, Status)) :-
  204    Count0 > 0,
  205    Count is Count0 - 1.
  206
  207%!  start_mail_scheduler
  208%
  209%   Start a thread that schedules queued mail handling.
  210
  211:- dynamic mail_scheduler_running/0.  212
  213start_mail_scheduler :-
  214    mail_scheduler_running,
  215    !.
  216start_mail_scheduler :-
  217    catch(thread_create(mail_main, _,
  218                        [ alias(mail_scheduler),
  219                          detached(true),
  220                          at_exit(retractall(mail_scheduler_running))
  221                        ]),
  222          error(permission_error(create, thread, mail_scheduler), _),
  223          true).
  224
  225%!  mail_main
  226%
  227%   Infinite loop that schedules sending queued messages.
  228
  229mail_main :-
  230    asserta(mail_scheduler_running),
  231    repeat,
  232    next_send_queue_time(T),
  233    get_time(Now),
  234    Sleep is T-Now,
  235    sleep(Sleep),
  236    thread_create(send_queued_mails, _,
  237                  [ detached(true),
  238                    alias(send_queued_mails)
  239                  ]),
  240    fail.
  241
  242next_send_queue_time(T) :-
  243    get_time(Now),
  244    stamp_date_time(Now, date(Y,M,D0,H0,_M,_S,Off,TZ,DST), local),
  245    setting(daily, HH:MM),
  246    (   H0 @< HH
  247    ->  D = D0
  248    ;   D is D0+1
  249    ),
  250    date_time_stamp(date(Y,M,D,HH,MM,0,Off,TZ,DST), T).
  251
  252%!  following(+DocID, ?ProfileID, ?Flags) is nondet.
  253
  254following(DocID, ProfileID, Flags) :-
  255    redis_docid_key(DocID, Server, Key),
  256    redis(Server, hgetall(Key), Pairs as pairs(atom, auto)),
  257    member(ProfileID-Flags, Pairs).
  258
  259%!  follow(+DocID, +ProfileID, +Flags) is det.
  260%
  261%   Assert that DocID is being followed by ProfileID using Flags.
  262
  263follow(DocID, ProfileID, Flags) :-
  264    redis_docid_key(DocID, Server, Key),
  265    !,
  266    (   Flags == []
  267    ->  redis(Server, hdel(Key, ProfileID))
  268    ;   maplist(to_atom, Flags, Options),
  269        redis(Server, hset(Key, ProfileID, Options as prolog))
  270    ).
  271follow(DocID, ProfileID, Flags) :-
  272    to_atom(DocID, DocIDA),
  273    to_atom(ProfileID, ProfileIDA),
  274    maplist(to_atom, Flags, Options),
  275    notify_open_db,
  276    (   follower(DocIDA, ProfileIDA, OldOptions)
  277    ->  (   OldOptions == Options
  278        ->  true
  279        ;   retractall_follower(DocIDA, ProfileIDA, _),
  280            (   Options \== []
  281            ->  assert_follower(DocIDA, ProfileIDA, Options)
  282            ;   true
  283            )
  284        )
  285    ;   Options \== []
  286    ->  assert_follower(DocIDA, ProfileIDA, Options)
  287    ;   true
  288    ).
  289
  290nofollow(DocID, ProfileID, Flags) :-
  291    redis_docid_key(DocID, Server, Key),
  292    !,
  293    maplist(to_atom, Flags, Options),
  294    (   redis(Server, hget(Key, ProfileID), OldOptions)
  295    ->  subtract(OldOptions, Options, NewOptions),
  296        follow(DocID, ProfileID, NewOptions)
  297    ;   true
  298    ).
  299nofollow(DocID, ProfileID, Flags) :-
  300    to_atom(DocID, DocIDA),
  301    to_atom(ProfileID, ProfileIDA),
  302    maplist(to_atom, Flags, Options),
  303    (   follower(DocIDA, ProfileIDA, OldOptions)
  304    ->  subtract(OldOptions, Options, NewOptions),
  305        follow(DocID, ProfileID, NewOptions)
  306    ;   true
  307    ).
  308
  309
  310%!  notify(+DocID, +Action) is det.
  311%
  312%   Action has been executed on DocID.  Notify all interested users.
  313%   Actions that may be notified:
  314%
  315%   - updated(Commit)
  316%     Gitty file was updated
  317%   - deleted(Commit)
  318%     Gitty file was deleted
  319%   - forked(OldCommit, Commit)
  320%     Gitty file was forked
  321%   - created(Commit)
  322%     A new gitty file was created
  323%   - chat(Message)
  324%     A chat message was sent.  Message is the JSON content as a dict.
  325%     Message contains a `docid` key.
  326
  327notify(DocID, Action) :-
  328    to_atom(DocID, DocIDA),
  329    try(notify_in_chat(DocIDA, Action)),
  330    notify_open_db,
  331    forall(following(DocIDA, Profile, Options),
  332           notify_user(Profile, DocIDA, Action, Options)).
  333
  334to_atom(Text, Atom) :-
  335    atom_string(Atom, Text).
  336
  337%!  notify_user(+Profile, +DocID, +Action, +Options)
  338%
  339%   Notify the user belonging to Profile  about Action, which is related
  340%   to document DocID.
  341
  342notify_user(Profile, _, Action, _Options) :-	% exclude self
  343    event_generator(Action, Profile),
  344    debug(notify(self), 'Notification to self ~p', [Profile]),
  345    \+ debugging(notify_self),
  346    !.
  347notify_user(Profile, DocID, Action, Options) :-
  348    try(notify_online(Profile, Action, Options)),
  349    try(notify_by_mail(Profile, DocID, Action, Options)).
  350
  351try(Goal) :-
  352    catch(Goal, Error, print_message(error, Error)),
  353    !.
  354try(_0Goal) :-
  355    debug(notify(fail), 'Failed: ~p', [_0Goal]).
  356
  357
  358		 /*******************************
  359		 *         BROADCAST API	*
  360		 *******************************/
  361
  362:- unlisten(swish(_)),
  363   listen(swish(Event), notify_event(Event)).  364
  365% request to follow this file
  366notify_event(follow(DocID, ProfileID, Options)) :-
  367    follow(DocID, ProfileID, Options).
  368% events on gitty files
  369notify_event(updated(File, Commit)) :-
  370    storage_meta_data(Commit.get(previous), OldCommit),
  371    (   atom_concat('gitty:', OldCommit.name, DocID)
  372    ->  notify(DocID, updated(Commit))
  373    ;   atom_concat('gitty:', File, DocID),
  374        notify(DocID, forked(OldCommit, Commit))
  375    ).
  376notify_event(deleted(File, Commit)) :-
  377    atom_concat('gitty:', File, DocID),
  378    notify(DocID, deleted(Commit)).
  379notify_event(created(File, Commit)) :-
  380    atom_concat('gitty:', File, DocID),
  381    notify(DocID, created(Commit)).
  382% chat message
  383notify_event(chat(Message)) :-
  384    notify(Message.docid, chat(Message)).
  385
  386%!  event_generator(+Event, -ProfileID) is semidet.
  387%
  388%   True when ProfileID refers to the user that initiated Event.
  389
  390event_generator(updated(Commit),   Commit.get(profile_id)).
  391event_generator(deleted(Commit),   Commit.get(profile_id)).
  392event_generator(forked(_, Commit), Commit.get(profile_id)).
  393
  394
  395		 /*******************************
  396		 *     NOTIFY PEOPLE ONLINE	*
  397		 *******************************/
  398
  399notify_online(ProfileID, Action, _Options) :-
  400    chat_to_profile(ProfileID, \short_notice(Action)).
  401
  402short_notice(updated(Commit)) -->
  403    html([\committer(Commit), ' updated ', \file_name(Commit)]).
  404short_notice(deleted(Commit)) -->
  405    html([\committer(Commit), ' deleted ', \file_name(Commit)]).
  406short_notice(forked(OldCommit, Commit)) -->
  407    html([\committer(Commit), ' forked ', \file_name(OldCommit),
  408          ' into ', \file_name(Commit)
  409         ]).
  410short_notice(chat(Message)) -->
  411    html([\chat_user(Message), " chatted about ", \chat_file(Message)]).
  412
  413file_name(Commit) -->
  414    { http_link_to_id(web_storage, path_postfix(Commit.name), HREF) },
  415    html(a(href(HREF), Commit.name)).
  416
  417
  418		 /*******************************
  419		 *  ADD NOTIFICATIONS TO CHAT	*
  420		 *******************************/
  421
  422%!  notify_in_chat(+DocID, +Action)
  423
  424:- html_meta(html_string(html, -)).  425
  426notify_in_chat(_, chat(_)) :-
  427    !.
  428notify_in_chat(DocID, Action) :-
  429    html_string(\chat_notice(Action, Payload), HTML),
  430    action_user(Action, User),
  431    Message0 = _{ type:"chat-message",
  432                  class:"update",
  433                  html:HTML,
  434                  user:User,
  435                  create:false
  436                },
  437    (   Payload == []
  438    ->  Message = Message0
  439    ;   Message = Message0.put(payload, Payload)
  440    ),
  441    chat_about(DocID, Message).
  442
  443
  444html_string(HTML, String) :-
  445    phrase(html(HTML), Tokens),
  446    delete(Tokens, nl(_), SingleLine),
  447    with_output_to(string(String), print_html(SingleLine)).
  448
  449
  450chat_notice(updated(Commit), [_{type:update, name:Name,
  451                                commit:CommitHash, previous:PrevCommit}]) -->
  452    { _{name:Name, commit:CommitHash, previous:PrevCommit} :< Commit },
  453    html([b('Saved'), ' new version: ', \commit_message_summary(Commit)]).
  454chat_notice(deleted(Commit), []) -->
  455    html([b('Deleted'), ': ', \commit_message_summary(Commit)]).
  456chat_notice(forked(_OldCommit, Commit), []) -->
  457    html([b('Forked'), ' into ', \file_name(Commit), ': ',
  458          \commit_message_summary(Commit)
  459         ]).
  460chat_notice(created(Commit), []) -->
  461    html([b('Created'), ' ', \file_name(Commit), ': ',
  462          \commit_message_summary(Commit)
  463         ]).
  464
  465commit_message_summary(Commit) -->
  466    { Message = Commit.get(commit_message) }, !,
  467    html(span(class(['commit-message']), Message)).
  468commit_message_summary(_Commit) -->
  469    html(span(class(['no-commit-message']), 'no message')).
  470
  471%!  action_user(+Action, -User) is det.
  472%
  473%   Describe a user for chat purposes.  Such a user is identified by the
  474%   `profile_id`, `name` and/or `avatar`.
  475
  476action_user(Action, User) :-
  477    action_commit(Action, Commit),
  478    findall(Name-Value, commit_user_property(Commit, Name, Value), Pairs),
  479    dict_pairs(User, u, Pairs).
  480
  481action_commit(forked(_From, Commit), Commit) :-
  482    !.
  483action_commit(Action, Commit) :-
  484    arg(1, Action, Commit).
  485
  486commit_user_property(Commit, Name, Value) :-
  487    Profile = Commit.get(profile_id),
  488    !,
  489    profile_user_property(Profile, Commit, Name, Value).
  490commit_user_property(Commit, name, Name) :-
  491    Name = Commit.get(author).
  492commit_user_property(Commit, avatar, Avatar) :-
  493    Avatar = Commit.get(avatar).
  494
  495profile_user_property(ProfileID, _,      profile_id, ProfileID).
  496profile_user_property(_,         Commit, name,       Commit.get(author)).
  497profile_user_property(ProfileID, Commit, avatar,     Avatar) :-
  498    (   profile_property(ProfileID, avatar(Avatar))
  499    ->  true
  500    ;   Avatar = Commit.get(avatar)
  501    ).
  502
  503
  504		 /*******************************
  505		 *            EMAIL		*
  506		 *******************************/
  507
  508% ! notify_by_mail(+Profile, +DocID, +Action, +FollowOptions) is semidet.
  509%
  510%   Send a notification by mail. Optionally  schedules the message to be
  511%   send later.
  512%
  513%   @tbd: if sending fails, should we queue the message?
  514
  515notify_by_mail(Profile, DocID, Action, Options) :-
  516    profile_property(Profile, email(Email)),
  517    profile_property(Profile, email_notifications(When)),
  518    When \== never,
  519    must_notify(Action, Options),
  520    (   When == immediate
  521    ->  debug(notify(email), 'Sending notification mail to ~p', [Profile]),
  522        send_notification_mail(Profile, DocID, Email, Action)
  523    ;   debug(notify(email), 'Queing notification mail to ~p', [Profile]),
  524        queue_event(Profile, DocID, Action)
  525    ).
  526
  527must_notify(chat(Message), Options) :- !,
  528    memberchk(chat, Options),
  529    \+ Message.get(class) == "update".
  530must_notify(_, Options) :-
  531    memberchk(update, Options).
  532
  533% ! send_notification_mail(+Profile, +DocID, +Email, +Action) is semidet.
  534%
  535%   Actually send a notification mail.  Fails   if  Profile  has no mail
  536%   address or does not want to be notified by email.
  537
  538send_notification_mail(Profile, DocID, Email, Action) :-
  539    phrase(subject(Action), Codes),
  540    string_codes(Subject, Codes),
  541    smtp_send_html(Email, \mail_message(Profile, DocID, Action),
  542                   [ subject(Subject)
  543                   ]).
  544
  545subject(Action) -->
  546    subject_action(Action).
  547
  548subject_action(updated(Commit)) -->
  549    txt_commit_file(Commit), " updated by ", txt_committer(Commit).
  550subject_action(deleted(Commit)) -->
  551    txt_commit_file(Commit), " deleted by ", txt_committer(Commit).
  552subject_action(forked(_, Commit)) -->
  553    txt_commit_file(Commit), " forked by ", txt_committer(Commit).
  554subject_action(chat(Message)) -->
  555    txt_chat_user(Message), " chatted about ", txt_chat_file(Message).
  556
  557
  558		 /*******************************
  559		 *             STYLE		*
  560		 *******************************/
  561
  562style -->
  563    email_style,
  564    notify_style.
  565
  566notify_style -->
  567    html({|html||
  568<style>
  569 .block            {margin-left: 2em;}
  570p.commit-message,
  571p.chat             {color: darkgreen;}
  572p.nocommit-message {color: orange;}
  573pre.query          {}
  574div.query	   {margin-top:2em; border-top: 1px solid #888;}
  575div.query-title	   {font-size: 80%; color: #888;}
  576div.nofollow	   {margin-top:2em; border-top: 1px solid #888;
  577                    font-size: 80%; color: #888; }
  578</style>
  579         |}).
  580
  581
  582
  583
  584		 /*******************************
  585		 *            HTML BODY		*
  586		 *******************************/
  587
  588%!  message(+ProfileID, +DocID, +Action)//
  589
  590mail_message(ProfileID, DocID, Action) -->
  591    dear(ProfileID),
  592    notification(Action),
  593    unsubscribe_options(ProfileID, DocID, Action),
  594    signature,
  595    style.
  596
  597notification(updated(Commit)) -->
  598    html(p(['The file ', \global_commit_file(Commit),
  599            ' has been updated by ', \committer(Commit), '.'])),
  600    commit_message(Commit).
  601notification(forked(OldCommit, Commit)) -->
  602    html(p(['The file ', \global_commit_file(OldCommit),
  603            ' has been forked into ', \global_commit_file(Commit), ' by ',
  604            \committer(Commit), '.'])),
  605    commit_message(Commit).
  606notification(deleted(Commit)) -->
  607    html(p(['The file ', \global_commit_file(Commit),
  608            ' has been deleted by ', \committer(Commit), '.'])),
  609    commit_message(Commit).
  610notification(chat(Message)) -->
  611    html(p([\chat_user(Message), " chatted about ", \chat_file(Message)])),
  612    chat_message(Message).
  613
  614global_commit_file(Commit) -->
  615    global_gitty_link(Commit.name).
  616
  617global_gitty_link(File) -->
  618    { public_url(web_storage, path_postfix(File), HREF, []) },
  619    html(a(href(HREF), File)).
  620
  621committer(Commit) -->
  622    { ProfileID = Commit.get(profile_id) }, !,
  623    profile_name(ProfileID).
  624committer(Commit) -->
  625    html(Commit.get(owner)).
  626
  627commit_message(Commit) -->
  628    { Message = Commit.get(commit_message) }, !,
  629    html(p(class(['commit-message', block]), Message)).
  630commit_message(_Commit) -->
  631    html(p(class(['no-commit-message', block]), 'No message')).
  632
  633chat_file(Message) -->
  634    global_docid_link(Message.docid).
  635
  636global_docid_link(DocID) -->
  637    { string_concat("gitty:", File, DocID)
  638    },
  639    global_gitty_link(File).
  640
  641chat_user(Message) -->
  642    { User = Message.get(user).get(name) },
  643    !,
  644    html(User).
  645chat_user(_Message) -->
  646    html("Someone").
  647
  648chat_message(Message) -->
  649    (chat_text(Message)                  -> [] ; []),
  650    (chat_payloads(Message.get(payload)) -> [] ; []).
  651
  652chat_text(Message) -->
  653    html(p(class([chat,block]), Message.get(text))).
  654
  655chat_payloads([]) --> [].
  656chat_payloads([H|T]) --> chat_payload(H), chat_payloads(T).
  657
  658chat_payload(PayLoad) -->
  659    { atom_string(Type, PayLoad.get(type)) },
  660    chat_payload(Type, PayLoad),
  661    !.
  662chat_payload(_) --> [].
  663
  664chat_payload(query, PayLoad) -->
  665    html(div(class(query),
  666             [ div(class('query-title'), 'Query'),
  667               pre(class([query, block]), PayLoad.get(query))
  668             ])).
  669chat_payload(about, PayLoad) -->
  670    html(div(class(about),
  671             [ 'About file ', \global_docid_link(PayLoad.get(docid)) ])).
  672chat_payload(Type, _) -->
  673    html(p(['Unknown payload of type ~q'-[Type]])).
  674
  675
  676		 /*******************************
  677		 *          UNSUBSCRIBE		*
  678		 *******************************/
  679
  680unsubscribe_options(ProfileID, DocID, _) -->
  681    html(div(class(nofollow),
  682             [ 'Stop following ',
  683               \nofollow_link(ProfileID, DocID, [chat]), '||',
  684               \nofollow_link(ProfileID, DocID, [update]), '||',
  685               \nofollow_link(ProfileID, DocID, [chat,update]),
  686               ' about this document'
  687             ])).
  688
  689nofollow_link(ProfileID, DocID, What) -->
  690    email_action_link(\nofollow_link_label(What),
  691                      nofollow_page(ProfileID, DocID, What),
  692                      nofollow(ProfileID, DocID, What),
  693                      []).
  694
  695nofollow_link_label([chat])         --> html(chats).
  696nofollow_link_label([update])       --> html(updates).
  697nofollow_link_label([chat, update]) --> html('all notifications').
  698
  699nofollow_done([chat])         --> html(chat).
  700nofollow_done([update])       --> html(update).
  701nofollow_done([chat, update]) --> html('any notifications').
  702
  703nofollow_page(ProfileID, DocID, What, _Request) :-
  704    reply_html_page(
  705        email_confirmation,
  706        title('SWISH -- Stopped following'),
  707        [ \email_style,
  708          \dear(ProfileID),
  709          p(['You will no longer receive ', \nofollow_done(What),
  710             'notifications about ', \docid_link(DocID), '. ',
  711             'You can reactivate following this document using the \c
  712              File/Follow ... menu in SWISH.  You can specify whether \c
  713              and when you like to receive email notifications from your \c
  714              profile page.'
  715            ]),
  716          \signature
  717        ]).
  718
  719docid_link(DocID) -->
  720    { atom_concat('gitty:', File, DocID),
  721      http_link_to_id(web_storage, path_postfix(File), HREF)
  722    },
  723    !,
  724    html(a(href(HREF), File)).
  725docid_link(DocID) -->
  726    html(DocID).
  727
  728
  729		 /*******************************
  730		 *  TEXT RULES ON GITTY COMMITS	*
  731		 *******************************/
  732
  733txt_commit_file(Commit) -->
  734    write(Commit.name).
  735
  736txt_committer(Commit) -->
  737    { ProfileID = Commit.get(profile_id) }, !,
  738    txt_profile_name(ProfileID).
  739txt_committer(Commit) -->
  740    write(Commit.get(owner)), !.
  741
  742
  743
  744		 /*******************************
  745		 *    RULES ON GITTY COMMITS	*
  746		 *******************************/
  747
  748txt_profile_name(ProfileID) -->
  749    { profile_property(ProfileID, name(Name)) },
  750    write(Name).
  751
  752
  753		 /*******************************
  754		 *    RULES ON CHAT MESSAGES	*
  755		 *******************************/
  756
  757txt_chat_user(Message) -->
  758    { User = Message.get(user).get(name) },
  759    !,
  760    write(User).
  761txt_chat_user(_Message) -->
  762    "Someone".
  763
  764txt_chat_file(Message) -->
  765    { string_concat("gitty:", File, Message.docid) },
  766    !,
  767    write(File).
  768
  769
  770		 /*******************************
  771		 *            BASICS		*
  772		 *******************************/
  773
  774write(Term, Head, Tail) :-
  775    format(codes(Head, Tail), '~w', [Term]).
  776
  777
  778		 /*******************************
  779		 *        HTTP HANDLING		*
  780		 *******************************/
  781
  782:- http_handler(swish(follow/options), follow_file_options,
  783                [ id(follow_file_options) ]).  784:- http_handler(swish(follow/save), save_follow_file,
  785                [ id(save_follow_file) ]).  786
  787%!  follow_file_options(+Request)
  788%
  789%   Edit the file following options for the current user.
  790
  791follow_file_options(Request) :-
  792    http_parameters(Request,
  793                    [ docid(DocID, [atom])
  794                    ]),
  795    http_in_session(_SessionID),
  796    http_session_data(profile_id(ProfileID)), !,
  797    (   profile_property(ProfileID, email_notifications(When))
  798    ->  true
  799    ;   existence_error(profile_property, email_notifications)
  800    ),
  801
  802    (   following(DocID, ProfileID, Follow)
  803    ->  true
  804    ;   Follow = []
  805    ),
  806
  807    follow_file_widgets(DocID, When, Follow, Widgets),
  808
  809    reply_html_page(
  810        title('Follow file options'),
  811        \bt_form(Widgets,
  812                 [ class('form-horizontal'),
  813                   label_columns(sm-3)
  814                 ])).
  815follow_file_options(_Request) :-
  816    reply_html_page(
  817        title('Follow file options'),
  818        [ p('You must be logged in to follow a file'),
  819          \bt_form([ button_group(
  820                         [ button(cancel, button,
  821                                  [ type(danger),
  822                                    data([dismiss(modal)])
  823                                  ])
  824                         ], [])
  825                   ],
  826                   [ class('form-horizontal'),
  827                     label_columns(sm-3)
  828                   ])
  829        ]).
  830
  831:- multifile
  832    user_profile:attribute/3.  833
  834follow_file_widgets(DocID, When, Follow,
  835    [ hidden(docid, DocID),
  836      checkboxes(follow, [update,chat], [value(Follow)]),
  837      select(email_notifications, NotificationOptions, [value(When)])
  838    | Buttons
  839    ]) :-
  840    user_profile:attribute(email_notifications, oneof(NotificationOptions), _),
  841    buttons(Buttons).
  842
  843buttons(
  844    [ button_group(
  845          [ button(save, submit,
  846                   [ type(primary),
  847                     data([action(SaveHREF)])
  848                   ]),
  849            button(cancel, button,
  850                   [ type(danger),
  851                     data([dismiss(modal)])
  852                   ])
  853          ],
  854          [
  855          ])
  856    ]) :-
  857    http_link_to_id(save_follow_file, [], SaveHREF).
  858
  859%!  save_follow_file(+Request)
  860%
  861%   Save the follow file options
  862
  863save_follow_file(Request) :-
  864    http_read_json_dict(Request, Dict),
  865    debug(profile(update), 'Got ~p', [Dict]),
  866    http_in_session(_SessionID),
  867    http_session_data(profile_id(ProfileID)),
  868    debug(notify(options), 'Set follow options to ~p', [Dict]),
  869    set_profile(ProfileID, email_notifications=Dict.get(email_notifications)),
  870    follow(Dict.get(docid), ProfileID, Dict.get(follow)),
  871    reply_json_dict(_{status:success})