File Coverage

blib/lib/Circle/Net/Matrix/Room.pm
Criterion Covered Total %
statement 33 250 13.2
branch 0 56 0.0
condition 0 45 0.0
subroutine 11 45 24.4
pod 0 31 0.0
total 44 427 10.3


line stmt bran cond sub pod time code
1             # You may distribute under the terms of the GNU General Public License
2             #
3             # (C) Paul Evans, 2014-2017 -- leonerd@leonerd.org.uk
4              
5             package Circle::Net::Matrix::Room;
6              
7 1     1   986 use strict;
  1         2  
  1         25  
8 1     1   3 use warnings;
  1         1  
  1         25  
9 1     1   3 use base qw( Tangence::Object Circle::WindowItem );
  1         1  
  1         114  
10              
11             our $VERSION = '0.03';
12              
13 1     1   5 use Data::Dump qw( pp );
  1         2  
  1         47  
14 1     1   4 use Scalar::Util qw( weaken );
  1         1  
  1         37  
15              
16 1     1   468 use Net::Async::Matrix::Utils qw( parse_formatted_message build_formatted_message );
  1         135408  
  1         75  
17 1     1   6 use Circle::TaggedString;
  1         1  
  1         21  
18              
19 1     1   461 use Circle::Net::Matrix::Utils qw( parse_markdownlike );
  1         1  
  1         1762  
20              
21             # To allow for out-of-tree development, use an inline Tangence class
22             # declaration instead of a .tan file
23             #
24             # class Circle.Net.Matrix.Room {
25             # isa Circle.WindowItem;
26             #
27             # smashed prop name = str;
28             # smashed prop topic = str;
29             # }
30              
31             sub DECLARE_TANGENCE
32             {
33 0     0 0   Tangence::Class->declare( __PACKAGE__,
34             props => {
35             name => {
36             dim => Tangence::Constants::DIM_SCALAR,
37             type => 'str',
38             },
39             },
40              
41             superclasses => [qw( Circle::WindowItem )],
42             );
43             }
44              
45             sub WEAKSELF_EVAL
46             {
47 0     0 0   my ( $self, $method ) = @_;
48 0 0   0     my $code = $self->can( $method ) or return sub {};
49              
50 0           weaken( $self );
51             return sub {
52 0     0     my @args = @_;
53 0 0         eval { $self->$code( @args ); 1 } or
  0            
  0            
54             warn $@;
55 0           };
56             }
57              
58 0     0 0   sub init_prop_topic { "" }
59              
60             sub new
61             {
62 0     0 0   my $class = shift;
63 0           my %args = @_;
64              
65 0           my $self = $class->SUPER::new( @_ );
66              
67 0           my $room = $self->{room} = $args{room};
68              
69 0           $self->set_prop_name( $room->room_id );
70 0           $self->set_prop_tag( $room->room_id );
71              
72 0           $self->{root} = $args{root};
73 0           $self->{net} = $args{net};
74              
75 0           weaken( my $weakself = $self );
76 0           $room->configure(
77             on_synced_state => $self->WEAKSELF_EVAL( 'on_synced_state' ),
78              
79             on_message => $self->WEAKSELF_EVAL( 'on_message' ),
80             on_membership => $self->WEAKSELF_EVAL( 'on_membership' ),
81             on_state_changed => $self->WEAKSELF_EVAL( 'on_state_changed' ),
82             on_members_typing => $self->WEAKSELF_EVAL( 'on_members_typing' ),
83             on_read_receipt => $self->WEAKSELF_EVAL( 'on_read_receipt' ),
84             );
85              
86             # TODO: this entire state-watching system likely wants to be migrated into
87             # NaMatrix itself
88             $self->{state_watches} = {
89 0           'm.room.name' => $self->can( 'on_state_name' ),
90             'm.room.topic' => $self->can( 'on_state_topic' ),
91             'm.room.member*' => $self->can( 'on_state_members' ),
92              
93             'm.room.join_rules' => $self->can( 'on_state_permission' ),
94             'm.room.history_visibility' => $self->can( 'on_state_permission' ),
95             };
96              
97 0           return $self;
98             }
99              
100             # Convenience accessor
101             sub name
102             {
103 0     0 0   my $self = shift;
104 0           return $self->get_prop_name;
105             }
106              
107             sub enumerable_name
108             {
109 0     0 0   my $self = shift;
110 0           return $self->name;
111             }
112              
113             sub get_prop_tag
114             {
115 0     0 0   my $self = shift;
116 0           return $self->name;
117             }
118              
119             sub parent
120             {
121 0     0 0   my $self = shift;
122 0           return $self->{net};
123             }
124              
125             sub commandable_parent
126             {
127 0     0 0   my $self = shift;
128 0           return $self->parent;
129             }
130              
131             sub on_synced_state
132             {
133 0     0 0   my $self = shift;
134              
135 0           my $room = $self->{room};
136              
137 0           my $state = $room->live_state;
138              
139 0           my $watches = $self->{state_watches};
140 0           foreach my $type ( keys %$watches ) {
141             # TODO: avoid doing the name ones twice in here somehow
142 0           my $code = $watches->{$type};
143              
144 0 0         if( $type =~ m/^(.*)\*$/ ) {
145 0           $code->( $self, $state->get_events( $1 ) );
146             }
147             else {
148 0           $code->( $self, $state->get_event( $type ) );
149             }
150             }
151              
152 0           my $name = $self->_generate_roomname( $state );
153              
154 0           $self->set_prop_name( $name );
155 0           $self->set_prop_tag( $name );
156             }
157              
158             sub on_state_name
159             {
160 0     0 0   my $self = shift;
161              
162 0           my $state = $self->{room}->live_state;
163              
164 0           my $name = $self->_generate_roomname( $state );
165              
166 0           $self->set_prop_name( $name );
167 0           $self->set_prop_tag( $name );
168             }
169              
170             sub _generate_roomname
171             {
172 0     0     my $self = shift;
173 0           my ( $state ) = @_;
174              
175 0           my $event;
176              
177             $event = $state->get_event( 'm.room.name' ) and
178 0 0         return $event->{content}{name};
179              
180 0           my $myself = $self->{net}{matrix}->myself;
181 0           my $domain = ( $myself->user_id =~ m/:(.*)$/ )[0];
182              
183             # TODO: pick canonical alias if there is one
184              
185 0           my %aliasmap = %{ $state->get_events( 'm.room.aliases' ) };
  0            
186              
187             # Prefer an alias defined by my own homeserver
188 0           my $local_aliases = $aliasmap{$domain};
189             $local_aliases and
190 0 0         return $local_aliases->{content}{aliases}[0];
191              
192             # TODO: try to stable-sort them
193 0           my @aliases = map { @{ $_->{content}{aliases} } }
  0            
  0            
194             values %aliasmap;
195              
196 0 0         return $aliases[0] if @aliases;
197              
198 0           my @others = grep { $_->user->user_id ne $myself->user_id } $state->members;
  0            
199              
200 0 0         return $others[0]->displayname if @others == 1;
201              
202 0           warn "Did not find exactly one peer for ${\ $self->{room}->room_id }; bailing out\n";
  0            
203 0           return $self->{room}->room_id;
204             }
205              
206             sub on_state_topic
207             {
208 0     0 0   my $self = shift;
209 0           my ( $event ) = @_;
210              
211 0           my $topic = $event->{content}{topic};
212              
213 0           $self->get_widget_topic->set_prop_text( $topic );
214             }
215              
216             sub on_state_members
217             {
218 0     0 0   my $self = shift;
219 0           my ( $eventmap ) = @_;
220              
221             # For now lets just count members by membership, but at some point we'd
222             # like to consider powerlevel or something too
223 0           my %members_by_membership;
224              
225 0           $members_by_membership{ $_->{content}{membership} }++ for values %$eventmap;
226              
227 0           my $count = "$members_by_membership{join} members";
228 0 0         $count .= " + $members_by_membership{invite} invited" if $members_by_membership{invite};
229             # Ignore "leave"
230              
231 0           $self->get_widget_membercount->set_prop_text( $count );
232              
233 0           my $my_userid = $self->{net}{matrix}->myself->user_id;
234              
235             $self->get_widget_displayname->set_prop_text(
236 0   0       $eventmap->{$my_userid }{content}{displayname} // $my_userid
237             );
238             }
239              
240             sub on_state_permission
241             {
242 0     0 0   my $self = shift;
243             # Ignore the passed event because we combine multiple
244              
245 0           my $state = $self->{room}->live_state;
246              
247 0           my $ev;
248             my @parts;
249              
250             # TODO: something about my own power level?
251              
252 0   0       push @parts, "J=" . ( $state->join_rule // "?" );
253              
254 0           $ev = $state->get_event( "m.room.history_visibility" );
255 0   0       push @parts, "V=" . ( ( $ev && $ev->{content}{history_visibility} ) // "?" );
      0        
256              
257 0           $self->get_widget_permission->set_prop_text( join "|", @parts );
258             }
259              
260             sub on_message
261             {
262 0     0 0   my $self = shift; shift;
  0            
263 0           my ( $member, $content, $event ) = @_;
264 0           my $member_id = $member->user->user_id;
265              
266 0           $self->{latest_event_id} = $event->{event_id};
267              
268 0           my $tstamp = $event->{origin_server_ts} / 1000;
269 0           my $type = $content->{msgtype};
270              
271 0           my $formatted_body = parse_formatted_message( $content );
272 0           my $text = Circle::TaggedString->new_from_formatting( $formatted_body );
273              
274 0           my ( $etype, $args );
275 0 0         if( $type eq "m.text" ) {
    0          
    0          
276 0           ( $etype, $args ) = ( "matrix.text" => {
277             name => $member->displayname,
278             user_id => $member_id,
279             text => $text,
280             });
281             }
282             elsif( $type eq "m.notice" ) {
283 0           ( $etype, $args ) = ( "matrix.notice" => {
284             name => $member->displayname,
285             user_id => $member_id,
286             text => $text,
287             });
288             }
289             elsif( $type eq "m.emote" ) {
290 0           ( $etype, $args ) = ( "matrix.emote" => {
291             name => $member->displayname,
292             user_id => $member_id,
293             text => $text,
294             });
295             }
296             else {
297 0           ( $etype, $args ) = ( "text" => {
298             text => "Unrecognised Matrix event msgtype <$type>"
299             });
300             }
301              
302 0           $self->push_displayevent( $etype, $args, time => $tstamp );
303 0           $self->bump_level( 2 );
304             }
305              
306             sub on_membership
307             {
308 0     0 0   my $self = shift; shift;
  0            
309 0           my ( $member, $event, $subject, %changes ) = @_;
310 0           my $member_id = $member->user->user_id;
311              
312 0           $self->{latest_event_id} = $event->{event_id};
313              
314 0           $self->on_state_members( $self->{room}->live_state->get_events( "m.room.member" ) );
315              
316 0           my $tstamp = $event->{origin_server_ts} / 1000;
317              
318 0 0         if( my $membership = $changes{membership} ) {
319 0 0 0       if( ( $membership->[0]//"") ne "join" and $membership->[1] eq "join" ) {
    0 0        
    0 0        
320 0           $self->push_displayevent( "matrix.join" => {
321             name => $member->displayname,
322             user_id => $member_id,
323             }, time => $tstamp );
324 0           $self->bump_level( 1 );
325             }
326             elsif( $membership->[0] eq "join" and !defined $membership->[1] ) {
327             # $member->displayname won't be set any more
328             $self->push_displayevent( "matrix.leave" => {
329 0   0       name => $changes{displayname}[0] // $member->displayname,
330             user_id => $member_id,
331             }, time => $tstamp );
332 0           $self->bump_level( 1 );
333             }
334             elsif( $membership->[1] eq "invite" ) {
335 0           $self->push_displayevent( "matrix.invite" => {
336             name => $member->displayname,
337             user_id => $member_id,
338             invitee => $subject->displayname,
339             invitee_id => $subject->user->user_id,
340             }, time => $tstamp );
341 0           $self->bump_level( 1 );
342             }
343             else {
344 0   0       $self->push_displayevent( "matrix.member" => {
345             name => $member->displayname,
346             user_id => $member_id,
347             subject => $subject->displayname // $subject->user->user_id,
348             state => "membership",
349             oldval => $membership->[0],
350             newval => $membership->[1],
351             }, time => $tstamp );
352             }
353              
354 0           return;
355             }
356              
357             # As a text-only client we don't care about avatar_url
358 0           delete $changes{avatar_url};
359              
360 0 0         if( my $displayname = $changes{displayname} ) {
    0          
    0          
361 0           $self->push_displayevent( "matrix.rename" => {
362             oldname => $displayname->[0],
363             newname => $displayname->[1],
364             user_id => $member_id,
365             }, time => $tstamp );
366 0           $self->bump_level( 1 );
367             }
368             elsif( my $level = $changes{level} ) {
369 0   0       $self->push_displayevent( "matrix.member" => {
370             name => $member->displayname,
371             user_id => $member_id,
372             subject => $subject->displayname // $subject->user->user_id,
373             state => "level",
374             oldval => $level->[0],
375             newval => $level->[1],
376             }, time => $tstamp );
377 0           $self->bump_level( 1 );
378             }
379             elsif( keys %changes ) { # ignore "empty" changes e.g. avatar_url
380             # TODO for debugging
381 0           $self->push_displayevent( text => {
382 0           text => "Member $member changed to ${\pp \%changes}"
383             }, time => $tstamp );
384              
385 0           $self->bump_level( 2 );
386             }
387             }
388              
389             my %key_for_event = (
390             "m.room.guest_access" => "guest_access",
391             "m.room.history_visibility" => "history_visibility",
392             "m.room.join_rules" => "join_rule", # sic
393             "m.room.name" => "name",
394             "m.room.topic" => "topic",
395             );
396              
397             sub on_state_changed
398             {
399 0     0 0   my $self = shift; shift;
  0            
400 0           my ( $member, $event, %changes ) = @_;
401 0 0         my $member_id = $member ? $member->user->user_id : undef;
402              
403 0           my $type = $event->{type};
404              
405 0           $self->{latest_event_id} = $event->{event_id};
406              
407 0           my $watches = $self->{state_watches};
408 0 0         if( my $code = $watches->{$type} ) {
409 0           $code->( $self, $event );
410             }
411              
412 0           my $tstamp = $event->{origin_server_ts} / 1000;
413              
414             # TODO: m.room.create event arrives before the m.room.member event for its
415             # creator, so we don't yet have a way to display it
416 0 0         return if $type eq "m.room.create";
417              
418             # As a text-only client we don't care about the room avatar
419 0 0         return if $type eq "m.room.avatar_url";
420              
421 0 0         if( my $key = $key_for_event{$type} ) {
422 0 0         return unless my $values = $changes{$key};
423              
424 0 0         $self->push_displayevent( "matrix.state" => {
425             name => $member ? $member->displayname : undef,
426             user_id => $member_id,
427             state => $key,
428             oldval => $values->[0],
429             newval => $values->[1],
430             }, time => $tstamp );
431              
432 0           $self->bump_level( 1 );
433             }
434             else {
435             # TODO for debugging
436 0           $self->push_displayevent( text => {
437 0           text => "Member $member changed room state $type to ${\pp \%changes}"
438             }, time => $tstamp );
439             }
440             }
441              
442             sub on_members_typing
443             {
444 0     0 0   my $self = shift; shift;
  0            
445 0           my @members = @_;
446              
447 0           my $widget = $self->get_widget_typing;
448              
449 0 0         if( !@members ) {
450 0           $widget->set_prop_text( "" );
451 0           return;
452             }
453              
454             $widget->set_prop_text( "(Typing: " .
455 0   0       join( ", ", map { $_->displayname // $_->user->user_id } @members ) .
  0            
456             ")"
457             );
458             }
459              
460             sub on_read_receipt
461             {
462 0     0 0   my $self = shift; shift;
  0            
463 0           my ( $member, $event_id, $content ) = @_;
464              
465             # TODO: maybe someday we'll care about other users, but not today
466 0           my $my_userid = $self->{net}{matrix}->myself->user_id;
467 0 0         return unless $member->user->user_id eq $my_userid;
468              
469 0 0         if( $event_id eq $self->{latest_event_id} ) {
470             # Reset level to zero; because another client has read up until latest
471             # message
472 0           $self->set_prop_level( 0 );
473             }
474             }
475              
476             sub method_reset_level
477             {
478 0     0 0   my $self = shift;
479 0           $self->SUPER::method_reset_level;
480              
481 0           my $room = $self->{room};
482              
483             my $f = $room->send_read_receipt(
484             event_id => $self->{latest_event_id},
485 0           );
486 0           $room->adopt_future( $f );
487             }
488              
489             sub enter_text
490             {
491 0     0 0   my $self = shift;
492 0           my ( $text ) = @_;
493              
494 0           my $content = build_formatted_message( parse_markdownlike( $text ) );
495              
496 0           my $room = $self->{room};
497 0           my $f = $room->send_message( type => "m.text", %$content );
498              
499 0           $room->adopt_future( $f );
500             }
501              
502             sub command_leave
503             : Command_description("Leave the room")
504             {
505 0     0 0 0 my $self = shift;
506 0         0 my ( $cinv ) = @_;
507              
508 0         0 my $matrix = $self->{net}{matrix};
509              
510 0         0 $matrix->leave_room( $self->{room}->room_id );
511              
512 0         0 return;
513 1     1   5 }
  1         1  
  1         6  
514              
515             sub command_say
516             : Command_description("Quote text directly as a text message")
517             : Command_arg('text', eatall => 1)
518             {
519 0     0 0 0 my $self = shift;
520 0         0 my ( $text ) = @_;
521              
522             # No markdown parsing
523              
524 0         0 $self->enter_text( $text );
525              
526 0         0 return;
527 1     1   367 }
  1         2  
  1         3  
528              
529             sub command_me
530             : Command_description("Send an emote message")
531             : Command_arg('text', eatall => 1)
532             {
533 0     0 0   my $self = shift;
534 0           my ( $text ) = @_;
535              
536 0           my $content = build_formatted_message( parse_markdownlike( $text ) );
537              
538 0           my $room = $self->{room};
539 0           my $f = $room->send_message( type => "m.emote", %$content );
540              
541 0           $room->adopt_future( $f );
542              
543 0           return;
544 1     1   352 }
  1         1  
  1         4  
545              
546             sub get_widget_topic
547             {
548 0     0 0   my $self = shift;
549             return $self->{topic_widget} //= $self->{registry}->construct(
550 0   0       "Circle::Widget::Entry",
551             classes => [qw( topic )],
552             # on_enter => sub { $self->topic( $_[0] ) },
553             );
554             }
555              
556             sub get_widget_displayname
557             {
558 0     0 0   my $self = shift;
559             return $self->{displayname_widget} //= $self->{registry}->construct(
560 0   0       "Circle::Widget::Label",
561             );
562             }
563              
564             sub get_widget_permission
565             {
566 0     0 0   my $self = shift;
567             return $self->{permission_widget} //= $self->{registry}->construct(
568 0   0       "Circle::Widget::Label",
569             );
570             }
571              
572             sub get_widget_typing
573             {
574 0     0 0   my $self = shift;
575             return $self->{typing_widget} //= $self->{registry}->construct(
576 0   0       "Circle::Widget::Label",
577             classes => [qw( transient )],
578             );
579             }
580              
581             sub get_widget_membercount
582             {
583 0     0 0   my $self = shift;
584             return $self->{membercount_widget} //= $self->{registry}->construct(
585 0   0       "Circle::Widget::Label",
586             );
587             }
588              
589             sub make_widget_pre_scroller
590             {
591 0     0 0   my $self = shift;
592 0           my ( $box ) = @_;
593              
594 0           $box->add( $self->get_widget_topic );
595             }
596              
597             sub get_widget_statusbar
598             {
599 0     0 0   my $self = shift;
600              
601 0           my $registry = $self->{registry};
602 0           my $net = $self->{net};
603              
604 0           my $statusbar = $registry->construct(
605             "Circle::Widget::Box",
606             classes => [qw( status )],
607             orientation => "horizontal",
608             );
609              
610 0           $statusbar->add( $net->get_widget_netname );
611              
612 0           $statusbar->add( $self->get_widget_displayname );
613              
614 0           $statusbar->add( $self->get_widget_permission );
615              
616 0           $statusbar->add_spacer( expand => 1 );
617              
618 0           $statusbar->add( $self->get_widget_typing );
619              
620 0           $statusbar->add( $self->get_widget_membercount );
621              
622 0           return $statusbar;
623             }
624              
625             0x55AA;