activity.php 27.1 KB
Newer Older
Evan Prodromou's avatar
Evan Prodromou committed
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
<?php
/**
 * StatusNet, the distributed open-source microblogging tool
 *
 * An activity
 *
 * PHP version 5
 *
 * LICENCE: This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 *
22
 * @category  Feed
Evan Prodromou's avatar
Evan Prodromou committed
23 24
 * @package   StatusNet
 * @author    Evan Prodromou <evan@status.net>
25
 * @author    Zach Copley <zach@status.net>
Evan Prodromou's avatar
Evan Prodromou committed
26 27 28 29 30 31 32 33 34
 * @copyright 2010 StatusNet, Inc.
 * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
 * @link      http://status.net/
 */

if (!defined('STATUSNET')) {
    exit(1);
}

35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
/**
 * An activity in the ActivityStrea.ms world
 *
 * An activity is kind of like a sentence: someone did something
 * to something else.
 *
 * 'someone' is the 'actor'; 'did something' is the verb;
 * 'something else' is the object.
 *
 * @category  OStatus
 * @package   StatusNet
 * @author    Evan Prodromou <evan@status.net>
 * @copyright 2010 StatusNet, Inc.
 * @license   http://www.fsf.org/licensing/licenses/agpl-3.0.html AGPLv3
 * @link      http://status.net/
 */
class Activity
{
    const SPEC   = 'http://activitystrea.ms/spec/1.0/';
    const SCHEMA = 'http://activitystrea.ms/schema/1.0/';
Zach Copley's avatar
Zach Copley committed
55
    const MEDIA  = 'http://purl.org/syndication/atommedia';
56 57 58 59 60 61 62 63 64 65

    const VERB       = 'verb';
    const OBJECT     = 'object';
    const ACTOR      = 'actor';
    const SUBJECT    = 'subject';
    const OBJECTTYPE = 'object-type';
    const CONTEXT    = 'context';
    const TARGET     = 'target';

    const ATOM = 'http://www.w3.org/2005/Atom';
Evan Prodromou's avatar
Evan Prodromou committed
66

67 68
    const AUTHOR    = 'author';
    const PUBLISHED = 'published';
69
    const UPDATED   = 'updated';
Evan Prodromou's avatar
Evan Prodromou committed
70

71 72 73 74 75 76 77 78 79 80 81 82 83 84
    const RSS = null; // no namespace!

    const PUBDATE     = 'pubDate';
    const DESCRIPTION = 'description';
    const GUID        = 'guid';
    const SELF        = 'self';
    const IMAGE       = 'image';
    const URL         = 'url';

    const DC = 'http://purl.org/dc/elements/1.1/';

    const CREATOR = 'creator';

    const CONTENTNS = 'http://purl.org/rss/1.0/modules/content/';
85
    const ENCODED = 'encoded';
86

87 88
    public $actor;   // an ActivityObject
    public $verb;    // a string (the URL)
Zach Copley's avatar
Zach Copley committed
89
    public $objects = array();  // an array of ActivityObjects
90 91 92 93 94 95 96
    public $target;  // an ActivityObject
    public $context; // an ActivityObject
    public $time;    // Time of the activity
    public $link;    // an ActivityObject
    public $entry;   // the source entry
    public $feed;    // the source feed

97 98 99 100
    public $summary; // summary of activity
    public $content; // HTML content of activity
    public $id;      // ID of the activity
    public $title;   // title of the activity
101
    public $categories = array(); // list of AtomCategory objects
102
    public $enclosures = array(); // list of enclosure URL references
103
    public $attachments = array(); // list of attachments
104

105 106 107 108
    public $extra = array(); // extra elements as array(tag, attrs, content)
    public $source;  // ActivitySource object representing 'home feed'
    public $selfLink; // <link rel='self' type='application/atom+xml'>
    public $editLink; // <link rel='edit' type='application/atom+xml'>
109
    public $generator; // ActivityObject representing the generating application
110 111 112 113 114 115
    /**
     * Turns a regular old Atom <entry> into a magical activity
     *
     * @param DOMElement $entry Atom entry to poke at
     * @param DOMElement $feed  Atom feed, for context
     */
116
    function __construct($entry = null, $feed = null)
Evan Prodromou's avatar
Evan Prodromou committed
117
    {
118 119 120 121
        if (is_null($entry)) {
            return;
        }

122
        // Insist on a feed's root DOMElement; don't allow a DOMDocument
123
        if ($feed instanceof DOMDocument) {
124
            throw new ClientException(
125 126
                // TRANS: Client exception thrown when a feed instance is a DOMDocument.
                _('Expecting a root feed element but got a whole XML document.')
127 128 129
            );
        }

130
        $this->entry = $entry;
131 132
        $this->feed  = $feed;

133 134 135 136 137 138
        if ($entry->namespaceURI == Activity::ATOM &&
            $entry->localName == 'entry') {
            $this->_fromAtomEntry($entry, $feed);
        } else if ($entry->namespaceURI == Activity::RSS &&
                   $entry->localName == 'item') {
            $this->_fromRssItem($entry, $feed);
139 140 141
        } else if ($entry->namespaceURI == Activity::SPEC &&
                   $entry->localName == 'object') {
            $this->_fromAtomEntry($entry, $feed);
142
        } else {
143
            // Low level exception. No need for i18n.
144 145 146 147 148 149
            throw new Exception("Unknown DOM element: {$entry->namespaceURI} {$entry->localName}");
        }
    }

    function _fromAtomEntry($entry, $feed)
    {
150 151 152 153 154 155
        $pubEl = $this->_child($entry, self::PUBLISHED, self::ATOM);

        if (!empty($pubEl)) {
            $this->time = strtotime($pubEl->textContent);
        } else {
            // XXX technically an error; being liberal. Good idea...?
156 157 158 159 160 161
            $updateEl = $this->_child($entry, self::UPDATED, self::ATOM);
            if (!empty($updateEl)) {
                $this->time = strtotime($updateEl->textContent);
            } else {
                $this->time = null;
            }
162 163
        }

164
        $this->link = ActivityUtils::getPermalink($entry);
165 166 167 168 169 170 171 172 173 174

        $verbEl = $this->_child($entry, self::VERB);

        if (!empty($verbEl)) {
            $this->verb = trim($verbEl->textContent);
        } else {
            $this->verb = ActivityVerb::POST;
            // XXX: do other implied stuff here
        }

175 176 177 178 179 180
        // get immediate object children

        $objectEls = ActivityUtils::children($entry, self::OBJECT, self::SPEC);

        if (count($objectEls) > 0) {
            foreach ($objectEls as $objectEl) {
181 182
                // Special case for embedded activities
                $objectType = ActivityUtils::childContent($objectEl, self::OBJECTTYPE, self::SPEC);
183
                if ((!empty($objectType) && $objectType == ActivityObject::ACTIVITY) || $this->verb == ActivityVerb::SHARE) {
184 185 186 187
                    $this->objects[] = new Activity($objectEl);
                } else {
                    $this->objects[] = new ActivityObject($objectEl);
                }
Zach Copley's avatar
Zach Copley committed
188
            }
189
        } else {
190
            // XXX: really?
Zach Copley's avatar
Zach Copley committed
191
            $this->objects[] = new ActivityObject($entry);
192 193 194 195 196
        }

        $actorEl = $this->_child($entry, self::ACTOR);

        if (!empty($actorEl)) {
197 198 199
            // Standalone <activity:actor> elements are a holdover from older
            // versions of ActivityStreams. Newer feeds should have this data
            // integrated straight into <atom:author>.
200 201 202

            $this->actor = new ActivityObject($actorEl);

203 204 205 206 207 208 209 210 211 212 213
            // Cliqset has bad actor IDs (just nickname of user). We
            // work around it by getting the author data and using its
            // id instead

            if (!preg_match('/^\w+:/', $this->actor->id)) {
                $authorEl = ActivityUtils::child($entry, 'author');
                if (!empty($authorEl)) {
                    $authorObj = new ActivityObject($authorEl);
                    $this->actor->id = $authorObj->id;
                }
            }
214 215
        } else if ($authorEl = $this->_child($entry, self::AUTHOR, self::ATOM)) {

216 217
            // An <atom:author> in the entry overrides any author info on
            // the surrounding feed.
218
            $this->actor = new ActivityObject($authorEl);
219

220 221 222 223 224 225
        } else if (!empty($feed) &&
                   $subjectEl = $this->_child($feed, self::SUBJECT)) {

            // Feed subject is used for things like groups.
            // Should actually possibly not be interpreted as an actor...?
            $this->actor = new ActivityObject($subjectEl);
226 227 228 229 230 231 232

        } else if (!empty($feed) && $authorEl = $this->_child($feed, self::AUTHOR,
                                                              self::ATOM)) {

            // If there's no <atom:author> on the entry, it's safe to assume
            // the containing feed's authorship info applies.
            $this->actor = new ActivityObject($authorEl);
233 234 235 236 237
        }

        $contextEl = $this->_child($entry, self::CONTEXT);

        if (!empty($contextEl)) {
238 239 240
            $this->context = new ActivityContext($contextEl);
        } else {
            $this->context = new ActivityContext($entry);
241 242 243 244 245 246
        }

        $targetEl = $this->_child($entry, self::TARGET);

        if (!empty($targetEl)) {
            $this->target = new ActivityObject($targetEl);
247
        } elseif (ActivityUtils::compareVerbs($this->verb, array(ActivityVerb::FAVORITE))) {
248 249
            // StatusNet didn't send a 'target' for their Favorite atom entries
            $this->target = clone($this->objects[0]);
250
        }
251 252 253 254

        $this->summary = ActivityUtils::childContent($entry, 'summary');
        $this->id      = ActivityUtils::childContent($entry, 'id');
        $this->content = ActivityUtils::getContent($entry);
255 256 257 258 259 260 261 262

        $catEls = $entry->getElementsByTagNameNS(self::ATOM, 'category');
        if ($catEls) {
            for ($i = 0; $i < $catEls->length; $i++) {
                $catEl = $catEls->item($i);
                $this->categories[] = new AtomCategory($catEl);
            }
        }
263 264 265 266

        foreach (ActivityUtils::getLinks($entry, 'enclosure') as $link) {
            $this->enclosures[] = $link->getAttribute('href');
        }
267 268 269

        // From APP. Might be useful.

270
        $this->selfLink = ActivityUtils::getSelfLink($entry);
271
        $this->editLink = ActivityUtils::getLink($entry, 'edit', 'application/atom+xml');
Evan Prodromou's avatar
Evan Prodromou committed
272 273
    }

274
    function _fromRssItem($item, $channel)
275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290
    {
        $verbEl = $this->_child($item, self::VERB);

        if (!empty($verbEl)) {
            $this->verb = trim($verbEl->textContent);
        } else {
            $this->verb = ActivityVerb::POST;
            // XXX: do other implied stuff here
        }

        $pubDateEl = $this->_child($item, self::PUBDATE, self::RSS);

        if (!empty($pubDateEl)) {
            $this->time = strtotime($pubDateEl->textContent);
        }

291
        if ($authorEl = $this->_child($item, self::AUTHOR, self::RSS)) {
292
            $this->actor = ActivityObject::fromRssAuthor($authorEl);
293 294 295 296 297 298 299
        } else if ($dcCreatorEl = $this->_child($item, self::CREATOR, self::DC)) {
            $this->actor = ActivityObject::fromDcCreator($dcCreatorEl);
        } else if ($posterousEl = $this->_child($item, ActivityObject::AUTHOR, ActivityObject::POSTEROUS)) {
            // Special case for Posterous.com
            $this->actor = ActivityObject::fromPosterousAuthor($posterousEl);
        } else if (!empty($channel)) {
            $this->actor = ActivityObject::fromRssChannel($channel);
300
        } else {
301
            // No actor!
302 303 304 305
        }

        $this->title = ActivityUtils::childContent($item, ActivityObject::TITLE, self::RSS);

306
        $contentEl = ActivityUtils::child($item, self::ENCODED, self::CONTENTNS);
307 308

        if (!empty($contentEl)) {
309 310
            // <content:encoded> XML node's text content is HTML; no further processing needed.
            $this->content = $contentEl->textContent;
311 312 313
        } else {
            $descriptionEl = ActivityUtils::child($item, self::DESCRIPTION, self::RSS);
            if (!empty($descriptionEl)) {
314 315 316 317 318 319 320
                // Per spec, <description> must be plaintext.
                // In practice, often there's HTML... but these days good
                // feeds are using <content:encoded> which is explicitly
                // real HTML.
                // We'll treat this following spec, and do HTML escaping
                // to convert from plaintext to HTML.
                $this->content = htmlspecialchars($descriptionEl->textContent);
321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339
            }
        }

        $this->link = ActivityUtils::childContent($item, ActivityUtils::LINK, self::RSS);

        // @fixme enclosures
        // @fixme thumbnails... maybe

        $guidEl = ActivityUtils::child($item, self::GUID, self::RSS);

        if (!empty($guidEl)) {
            $this->id = $guidEl->textContent;

            if ($guidEl->hasAttribute('isPermaLink') && $guidEl->getAttribute('isPermaLink') != 'false') {
                // overwrites <link>
                $this->link = $this->id;
            }
        }

Zach Copley's avatar
Zach Copley committed
340 341
        $this->objects[] = new ActivityObject($item);
        $this->context   = new ActivityContext($item);
342 343
    }

344 345 346 347 348
    /**
     * Returns an Atom <entry> based on this activity
     *
     * @return DOMElement Atom entry
     */
349

Evan Prodromou's avatar
Evan Prodromou committed
350 351
    function toAtomEntry()
    {
352
        return null;
Evan Prodromou's avatar
Evan Prodromou committed
353
    }
354

355 356 357 358 359 360 361 362 363 364 365 366 367 368
    /**
     * Returns an array based on this activity suitable
     * for encoding as a JSON object
     *
     * @return array $activity
     */

    function asArray()
    {
        $activity = array();

        // actor
        $activity['actor'] = $this->actor->asArray();

369 370
        // content
        $activity['content'] = $this->content;
371

372 373 374 375 376
        // generator

        if (!empty($this->generator)) {
            $activity['generator'] = $this->generator->asArray();
        }
377

378
        // icon <-- possibly a mini object representing verb?
379

380 381
        // id
        $activity['id'] = $this->id;
382 383

        // object
384

385
        if (count($this->objects) == 0) {
386 387
            common_log(LOG_ERR, "Can't save " . $this->id);
        } else {
388 389 390
            if (count($this->objects) > 1) {
                common_log(LOG_WARNING, "Ignoring " . (count($this->objects) - 1) . " extra objects in JSON output for activity " . $this->id);
            }
391 392 393 394
            $object = $this->objects[0];

            if ($object instanceof Activity) {
                // Sharing a post activity is more like sharing the original object
Evan Prodromou's avatar
Evan Prodromou committed
395 396
                if (ActivityVerb::canonical($this->verb) == ActivityVerb::canonical(ActivityVerb::SHARE) &&
                    ActivityVerb::canonical($object->verb) == ActivityVerb::canonical(ActivityVerb::POST)) {
397
                    // XXX: Here's one for the obfuscation record books
398
                    $object = $object->objects[0];
399 400 401 402 403 404 405 406
                }
            }

            $activity['object'] = $object->asArray();

            if ($object instanceof Activity) {
                $activity['object']['objectType'] = 'activity';
            }
407

408 409 410
            foreach ($this->attachments as $attachment) {
                if (empty($activity['object']['attachments'])) {
                    $activity['object']['attachments'] = array();
411
                }
412
                $activity['object']['attachments'][] = $attachment->asArray();
413
            }
414
        }
415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459
        
        // Context stuff.

        if (!empty($this->context)) {

            if (!empty($this->context->location)) {
                $loc = $this->context->location;

                $activity['location'] = array(
                    'objectType' => 'place',
                    'position' => sprintf("%+02.5F%+03.5F/", $loc->lat, $loc->lon),
                    'lat' => $loc->lat,
                    'lon' => $loc->lon
                );

                $name = $loc->getName();

                if ($name) {
                    $activity['location']['displayName'] = $name;
                }
                    
                $url = $loc->getURL();

                if ($url) {
                    $activity['location']['url'] = $url;
                }
            }

            $activity['to']      = $this->context->getToArray();

            $ctxarr = $this->context->asArray();

            if (array_key_exists('inReplyTo', $ctxarr)) {
                $activity['object']['inReplyTo'] = $ctxarr['inReplyTo'];
                unset($ctxarr['inReplyTo']);
            }

            if (!array_key_exists('status_net', $activity)) {
                $activity['status_net'] = array();
            }

            foreach ($ctxarr as $key => $value) {
                $activity['status_net'][$key] = $value;
            }
        }
460

461 462
        // published
        $activity['published'] = self::iso8601Date($this->time);
463

464 465 466 467 468 469 470 471
        // provider
        $provider = array(
            'objectType' => 'service',
            'displayName' => common_config('site', 'name'),
            'url' => common_root_url()
        );

        $activity['provider'] = $provider;
472 473 474 475 476 477 478 479 480

        // target
        if (!empty($this->target)) {
            $activity['target'] = $this->target->asArray();
        }

        // title
        $activity['title'] = $this->title;

481 482
        // updated <-- Optional. Should we use this to indicate the time we r
        //             eceived a remote notice? Probably not.
483 484

        // verb
485 486

        $activity['verb'] = ActivityVerb::canonical($this->verb);
487

488
        // url
489 490 491
        if ($this->link) {
            $activity['url'] = $this->link;
        }
492

493 494
        /* Purely extensions hereafter */

Evan Prodromou's avatar
Evan Prodromou committed
495 496 497 498 499 500 501 502 503 504 505 506
        if ($activity['verb'] == 'post') {
            $tags = array();
            foreach ($this->categories as $cat) {
                if (mb_strlen($cat->term) > 0) {
                    // Couldn't figure out which object type to use, so...
                    $tags[] = array('objectType' => 'http://activityschema.org/object/hashtag',
                                    'displayName' => $cat->term);
                }
            }
            if (count($tags) > 0) {
                $activity['object']['tags'] = $tags;
            }
507 508
        }

509 510 511
        // XXX: a bit of a hack... Since JSON isn't namespaced we probably
        // shouldn't be using 'statusnet:notice_info', but this will work
        // for the moment.
512

513 514
        foreach ($this->extra as $e) {
            list($objectName, $props, $txt) = $e;
515
            if (!empty($objectName)) {
516 517 518 519 520 521 522 523 524
                $parts = explode(":", $objectName);
                if (count($parts) == 2 && $parts[0] == "statusnet") {
                    if (!array_key_exists('status_net', $activity)) {
                        $activity['status_net'] = array();
                    }
                    $activity['status_net'][$parts[1]] = $props;
                } else {
                    $activity[$objectName] = $props;
                }
525
            }
526
        }
527

528
        return array_filter($activity);
529 530
    }

531
    function asString($namespace=false, $author=true, $source=false)
532 533
    {
        $xs = new XMLStringer(true);
534 535 536
        $this->outputTo($xs, $namespace, $author, $source);
        return $xs->getString();
    }
537

538
    function outputTo($xs, $namespace=false, $author=true, $source=false, $tag='entry')
539
    {
540 541
        if ($namespace) {
            $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
542
                           'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
543
                           'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
544 545
                           'xmlns:georss' => 'http://www.georss.org/georss',
                           'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
546
                           'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
547 548
                           'xmlns:media' => 'http://purl.org/syndication/atommedia',
                           'xmlns:statusnet' => 'http://status.net/schema/api/1/');
549 550 551 552
        } else {
            $attrs = array();
        }

553
        $xs->elementStart($tag, $attrs);
554

555 556 557 558 559
        if ($tag != 'entry') {
            $xs->element('activity:object-type', null, ActivityObject::ACTIVITY);
        }

        if ($this->verb == ActivityVerb::POST && count($this->objects) == 1 && $tag == 'entry') {
560

561
            $obj = $this->objects[0];
562
			$obj->outputTo($xs, null);
563 564 565

        } else {
            $xs->element('id', null, $this->id);
566 567 568 569 570 571 572

            if ($this->title) {
                $xs->element('title', null, $this->title);
            } else {
                // Require element
                $xs->element('title', null, "");
            }
573 574

            $xs->element('content', array('type' => 'html'), $this->content);
575

576 577 578
            if (!empty($this->summary)) {
                $xs->element('summary', null, $this->summary);
            }
579

580 581
            if (!empty($this->link)) {
                $xs->element('link', array('rel' => 'alternate',
582 583
                                           'type' => 'text/html',
                                           'href' => $this->link));
584
            }
585 586 587

        }

588
        $xs->element('activity:verb', null, $this->verb);
589

590
        $published = self::iso8601Date($this->time);
591

592 593
        $xs->element('published', null, $published);
        $xs->element('updated', null, $published);
594

595
        if ($author) {
596
            $this->actor->outputTo($xs, 'author');
597 598
        }

599
        if ($this->verb != ActivityVerb::POST || count($this->objects) != 1 || $tag != 'entry') {
Zach Copley's avatar
Zach Copley committed
600
            foreach($this->objects as $object) {
601 602 603 604 605
                if ($object instanceof Activity) {
                    $object->outputTo($xs, false, true, true, 'activity:object');
                } else {
                    $object->outputTo($xs, 'activity:object');
                }
Zach Copley's avatar
Zach Copley committed
606
            }
607 608
        }

609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627
        if (!empty($this->context)) {

            if (!empty($this->context->replyToID)) {
                if (!empty($this->context->replyToUrl)) {
                    $xs->element('thr:in-reply-to',
                                 array('ref' => $this->context->replyToID,
                                       'href' => $this->context->replyToUrl));
                } else {
                    $xs->element('thr:in-reply-to',
                                 array('ref' => $this->context->replyToID));
                }
            }

            if (!empty($this->context->replyToUrl)) {
                $xs->element('link', array('rel' => 'related',
                                           'href' => $this->context->replyToUrl));
            }

            if (!empty($this->context->conversation)) {
628 629 630 631 632 633
                $convattr = [];
                $conv = Conversation::getKV('uri', $this->context->conversation);
                if ($conv instanceof Conversation) {
                    $convattr['href'] = $conv->getUrl();
                    $convattr['local_id'] = $conv->getID();
                    $convattr['ref'] = $conv->getUri();
634
                    $xs->element('link', array('rel' => 'ostatus:'.ActivityContext::CONVERSATION,
635 636 637 638
                                                'href' => $convattr['href']));
                } else {
                    $convattr['ref'] = $this->context->conversation;
                }
639
                $xs->element('ostatus:'.ActivityContext::CONVERSATION,
640 641
                                $convattr,
                                $this->context->conversation);
642 643 644
                /* Since we use XMLWriter we just use the previously hardcoded prefix for ostatus,
                    otherwise we should use something like this:
                $xs->elementNS(array(ActivityContext::OSTATUS => 'ostatus'),    // namespace
645
                                ActivityContext::CONVERSATION,
646 647 648
                                null,   // attributes
                                $this->context->conversation);  // content
                */
649 650
            }

651
            foreach ($this->context->attention as $attnURI=>$type) {
652
                $xs->element('link', array('rel' => ActivityContext::MENTIONED,
653
                                           ActivityContext::OBJECTTYPE => $type,  // FIXME: undocumented 
654 655 656 657 658 659 660 661 662
                                           'href' => $attnURI));
            }

            if (!empty($this->context->location)) {
                $loc = $this->context->location;
                $xs->element('georss:point', null, $loc->lat . ' ' . $loc->lon);
            }
        }

663
        if ($this->target) {
664
            $this->target->outputTo($xs, 'activity:target');
665
        }
666

667
        foreach ($this->categories as $cat) {
668
            $cat->outputTo($xs);
669 670
        }

671
        // can be either URLs or enclosure objects
672

673 674
        foreach ($this->enclosures as $enclosure) {
            if (is_string($enclosure)) {
675 676
                $xs->element('link', array('rel' => 'enclosure',
                                           'href' => $enclosure));
677 678 679 680 681 682 683 684
            } else {
                $attributes = array('rel' => 'enclosure',
                                    'href' => $enclosure->url,
                                    'type' => $enclosure->mimetype,
                                    'length' => $enclosure->size);
                if ($enclosure->title) {
                    $attributes['title'] = $enclosure->title;
                }
685
                $xs->element('link', $attributes);
686 687 688 689 690
            }
        }

        // Info on the source feed

691
        if ($source && !empty($this->source)) {
692
            $xs->elementStart('source');
693

694 695 696 697 698 699 700 701
            $xs->element('id', null, $this->source->id);
            $xs->element('title', null, $this->source->title);

            if (array_key_exists('alternate', $this->source->links)) {
                $xs->element('link', array('rel' => 'alternate',
                                           'type' => 'text/html',
                                           'href' => $this->source->links['alternate']));
            }
702

703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720
            if (array_key_exists('self', $this->source->links)) {
                $xs->element('link', array('rel' => 'self',
                                           'type' => 'application/atom+xml',
                                           'href' => $this->source->links['self']));
            }

            if (array_key_exists('license', $this->source->links)) {
                $xs->element('link', array('rel' => 'license',
                                           'href' => $this->source->links['license']));
            }

            if (!empty($this->source->icon)) {
                $xs->element('icon', null, $this->source->icon);
            }

            if (!empty($this->source->updated)) {
                $xs->element('updated', null, $this->source->updated);
            }
721

722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737
            $xs->elementEnd('source');
        }

        if (!empty($this->selfLink)) {
            $xs->element('link', array('rel' => 'self',
                                       'type' => 'application/atom+xml',
                                       'href' => $this->selfLink));
        }

        if (!empty($this->editLink)) {
            $xs->element('link', array('rel' => 'edit',
                                       'type' => 'application/atom+xml',
                                       'href' => $this->editLink));
        }

        // For throwing in extra elements; used for statusnet:notice_info
738

739 740 741 742 743
        foreach ($this->extra as $el) {
            list($tag, $attrs, $content) = $el;
            $xs->element($tag, $attrs, $content);
        }

744
        $xs->elementEnd($tag);
745

746
        return;
747 748
    }

749 750
    private function _child($element, $tag, $namespace=self::SPEC)
    {
751
        return ActivityUtils::child($element, $tag, $namespace);
752
    }
753

754 755 756 757 758 759 760 761
    /**
     * For consistency, we'll always output UTC rather than local time.
     * Note that clients *should* accept any timezone we give them as long
     * as it's properly formatted.
     *
     * @param int $tm Unix timestamp
     * @return string
     */
762 763 764 765 766 767
    static function iso8601Date($tm)
    {
        $dateStr = date('d F Y H:i:s', $tm);
        $d = new DateTime($dateStr, new DateTimeZone('UTC'));
        return $d->format('c');
    }
768
}