We are no longer offering accounts on this server. Consider https://gitlab.freedesktop.org/ as a place to host projects.

activity.php 25.6 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

Evan Prodromou's avatar
Evan Prodromou committed
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';
Evan Prodromou's avatar
Evan Prodromou committed
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

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 110 111 112 113 114
    /**
     * 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
     */
115
    function __construct($entry = null, $feed = null)
Evan Prodromou's avatar
Evan Prodromou committed
116
    {
117 118 119 120
        if (is_null($entry)) {
            return;
        }

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

Evan Prodromou's avatar
Evan Prodromou committed
129
        $this->entry = $entry;
130 131
        $this->feed  = $feed;

Evan Prodromou's avatar
Evan Prodromou committed
132 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);
        } else {
139
            // Low level exception. No need for i18n.
Evan Prodromou's avatar
Evan Prodromou committed
140 141 142 143 144 145
            throw new Exception("Unknown DOM element: {$entry->namespaceURI} {$entry->localName}");
        }
    }

    function _fromAtomEntry($entry, $feed)
    {
146 147 148 149 150 151
        $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...?
152 153 154 155 156 157
            $updateEl = $this->_child($entry, self::UPDATED, self::ATOM);
            if (!empty($updateEl)) {
                $this->time = strtotime($updateEl->textContent);
            } else {
                $this->time = null;
            }
158 159
        }

160
        $this->link = ActivityUtils::getPermalink($entry);
161 162 163 164 165 166 167 168 169 170

        $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
        }

Zach Copley's avatar
Zach Copley committed
171
        $objectEls = $entry->getElementsByTagNameNS(self::SPEC, self::OBJECT);
172

Zach Copley's avatar
Zach Copley committed
173 174 175 176 177
        if ($objectEls->length > 0) {
            for ($i = 0; $i < $objectEls->length; $i++) {
                $objectEl = $objectEls->item($i);
                $this->objects[] = new ActivityObject($objectEl);
            }
178
        } else {
Zach Copley's avatar
Zach Copley committed
179
            $this->objects[] = new ActivityObject($entry);
180 181 182 183 184
        }

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

        if (!empty($actorEl)) {
185 186 187
            // Standalone <activity:actor> elements are a holdover from older
            // versions of ActivityStreams. Newer feeds should have this data
            // integrated straight into <atom:author>.
188 189 190

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

191 192 193 194 195 196 197 198 199 200 201
            // 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;
                }
            }
202 203
        } else if ($authorEl = $this->_child($entry, self::AUTHOR, self::ATOM)) {

204 205
            // An <atom:author> in the entry overrides any author info on
            // the surrounding feed.
206
            $this->actor = new ActivityObject($authorEl);
207

208 209 210 211 212 213
        } 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);
214 215 216 217 218 219 220

        } 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);
221 222 223 224 225
        }

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

        if (!empty($contextEl)) {
226 227 228
            $this->context = new ActivityContext($contextEl);
        } else {
            $this->context = new ActivityContext($entry);
229 230 231 232 233 234 235
        }

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

        if (!empty($targetEl)) {
            $this->target = new ActivityObject($targetEl);
        }
236 237 238 239

        $this->summary = ActivityUtils::childContent($entry, 'summary');
        $this->id      = ActivityUtils::childContent($entry, 'id');
        $this->content = ActivityUtils::getContent($entry);
240 241 242 243 244 245 246 247

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

        foreach (ActivityUtils::getLinks($entry, 'enclosure') as $link) {
            $this->enclosures[] = $link->getAttribute('href');
        }
252 253 254 255 256

        // From APP. Might be useful.

        $this->selfLink = ActivityUtils::getLink($entry, 'self', 'application/atom+xml');
        $this->editLink = ActivityUtils::getLink($entry, 'edit', 'application/atom+xml');
Evan Prodromou's avatar
Evan Prodromou committed
257 258
    }

259
    function _fromRssItem($item, $channel)
Evan Prodromou's avatar
Evan Prodromou committed
260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
    {
        $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);
        }

276
        if ($authorEl = $this->_child($item, self::AUTHOR, self::RSS)) {
277
            $this->actor = ActivityObject::fromRssAuthor($authorEl);
278 279 280 281 282 283 284
        } 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);
Evan Prodromou's avatar
Evan Prodromou committed
285
        } else {
286
            // No actor!
Evan Prodromou's avatar
Evan Prodromou committed
287 288 289 290
        }

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

291
        $contentEl = ActivityUtils::child($item, self::ENCODED, self::CONTENTNS);
Evan Prodromou's avatar
Evan Prodromou committed
292 293

        if (!empty($contentEl)) {
294 295
            // <content:encoded> XML node's text content is HTML; no further processing needed.
            $this->content = $contentEl->textContent;
Evan Prodromou's avatar
Evan Prodromou committed
296 297 298
        } else {
            $descriptionEl = ActivityUtils::child($item, self::DESCRIPTION, self::RSS);
            if (!empty($descriptionEl)) {
299 300 301 302 303 304 305
                // 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);
Evan Prodromou's avatar
Evan Prodromou committed
306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324
            }
        }

        $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
325 326
        $this->objects[] = new ActivityObject($item);
        $this->context   = new ActivityContext($item);
Evan Prodromou's avatar
Evan Prodromou committed
327 328
    }

329 330 331 332 333
    /**
     * Returns an Atom <entry> based on this activity
     *
     * @return DOMElement Atom entry
     */
334

Evan Prodromou's avatar
Evan Prodromou committed
335 336
    function toAtomEntry()
    {
337
        return null;
Evan Prodromou's avatar
Evan Prodromou committed
338
    }
339

340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356
    /**
     * 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();

        // body
        $activity['body'] = $this->content;

357 358 359 360 361
        // generator <-- We could use this when we know a notice is created
        //               locally. Or if we know the upstream Generator.

        // icon <-- I've decided to use the posting user's stream avatar here
        //          for now (also included in the avatarLinks extension)
362 363 364 365 366


        // object
        if ($this->verb == ActivityVerb::POST && count($this->objects) == 1) {
            $activity['object'] = $this->objects[0]->asArray();
367

368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388
            // Context stuff. For now I'm just sticking most of it
            // in a property called "context"

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

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

                    // GeoJSON

                    $activity['geopoint'] = array(
                        'type'        => 'Point',
                        'coordinates' => array($loc->lat, $loc->lon)
                    );

                }

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

389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430
            // Instead of adding enclosures as an extension to JSON
            // Activities, it seems like we should be using the
            // attachedObjects property of ActivityObject

            $attachedObjects = array();

            // XXX: OK, this is kinda cheating. We should probably figure out
            // what kind of objects these are based on mime-type and then
            // create specific object types. Right now this rely on
            // duck-typing.  Also, we should include an embed code for
            // video attachments.

            foreach ($this->enclosures as $enclosure) {

                if (is_string($enclosure)) {

                    $attachedObjects[]['id']  = $enclosure;

                } else {

                    $attachedObjects[]['id']  = $enclosure->url;

                    $mediaLink = new ActivityStreamsMediaLink(
                        $enclosure->url,
                        null,
                        null,
                        $enclosure->mimetype
                        // XXX: Add 'size' as an extension to MediaLink?
                    );

                    $attachedObjects[]['mediaLink'] = $mediaLink->asArray(); // extension

                    if ($enclosure->title) {
                        $attachedObjects[]['displayName'] = $enclosure->title;
                    }
               }
            }

            if (!empty($attachedObjects)) {
                $activity['object']['attachedObjects'] = $attachedObjects;
            }

431 432 433
        } else {
            $activity['object'] = array();
            foreach($this->objects as $object) {
434 435 436 437
                $oa = $object->asArray();
                if ($object instanceof Activity) {
                    // throw in a type
                    // XXX: hackety-hack
438
                    $oa['objectType'] = 'activity';
439 440
                }
                $activity['object'][] = $oa;
441 442 443 444 445
            }
        }

        $activity['postedTime'] = self::iso8601Date($this->time); // Change to exactly be RFC3339?

446 447 448 449 450 451 452 453
        // provider
        $provider = array(
            'objectType' => 'service',
            'displayName' => common_config('site', 'name'),
            'url' => common_root_url()
        );

        $activity['provider'] = $provider;
454 455 456 457 458 459 460 461 462

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

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

463 464
        // updatedTime <-- Should we use this to indicate the time we received
        //                 a remote notice? Probably not.
465 466

        // verb
467 468 469 470
        //
        // We can probably use the whole schema URL here but probably the
        // relative simple name is easier to parse
        $activity['verb'] = substr($this->verb, strrpos($this->verb, '/') + 1);
471

472 473
        /* Purely extensions hereafter */

474 475 476 477 478 479 480 481 482
        $tags = array();

        // Use an Activity Object for term? Which object? Note?
        foreach ($this->categories as $cat) {
            $tags[] = $cat->term;
        }

        $activity['tags'] = $tags;

Zach Copley's avatar
Zach Copley committed
483 484 485
        // 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.
486

Zach Copley's avatar
Zach Copley committed
487 488
        foreach ($this->extra as $e) {
            list($objectName, $props, $txt) = $e;
489 490 491
            if (!empty($objectName)) {
                $activity[$objectName] = $props;
            }
Zach Copley's avatar
Zach Copley committed
492
        }
493

494
        return array_filter($activity);
495 496
    }

497
    function asString($namespace=false, $author=true, $source=false)
498 499
    {
        $xs = new XMLStringer(true);
500 501 502
        $this->outputTo($xs, $namespace, $author, $source);
        return $xs->getString();
    }
503

504
    function outputTo($xs, $namespace=false, $author=true, $source=false, $tag='entry')
505
    {
506 507
        if ($namespace) {
            $attrs = array('xmlns' => 'http://www.w3.org/2005/Atom',
508
                           'xmlns:thr' => 'http://purl.org/syndication/thread/1.0',
509
                           'xmlns:activity' => 'http://activitystrea.ms/spec/1.0/',
510 511
                           'xmlns:georss' => 'http://www.georss.org/georss',
                           'xmlns:ostatus' => 'http://ostatus.org/schema/1.0',
512
                           'xmlns:poco' => 'http://portablecontacts.net/spec/1.0',
513 514
                           'xmlns:media' => 'http://purl.org/syndication/atommedia',
                           'xmlns:statusnet' => 'http://status.net/schema/api/1/');
515 516 517 518
        } else {
            $attrs = array();
        }

519
        $xs->elementStart($tag, $attrs);
520

521 522 523 524 525
        if ($tag != 'entry') {
            $xs->element('activity:object-type', null, ActivityObject::ACTIVITY);
        }

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

527
            $obj = $this->objects[0];
528
			$obj->outputTo($xs, null);
529 530 531 532 533 534

        } else {
            $xs->element('id', null, $this->id);
            $xs->element('title', null, $this->title);

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

536 537 538
            if (!empty($this->summary)) {
                $xs->element('summary', null, $this->summary);
            }
539

540 541 542 543 544
            if (!empty($this->link)) {
                $xs->element('link', array('rel' => 'alternate',
                                           'type' => 'text/html'),
                             $this->link);
            }
545 546 547

        }

548
        $xs->element('activity:verb', null, $this->verb);
549

550
        $published = self::iso8601Date($this->time);
551

552 553
        $xs->element('published', null, $published);
        $xs->element('updated', null, $published);
554

555
        if ($author) {
556
            $this->actor->outputTo($xs, 'author');
557 558 559 560 561 562 563 564 565 566 567 568

            // XXX: Remove <activity:actor> ASAP! Author information
            // has been moved to the author element in the Activity
            // Streams spec. We're outputting actor only for backward
            // compatibility with clients that can only parse
            // activities based on older versions of the spec.

            $depMsg = 'Deprecation warning: activity:actor is present '
                . 'only for backward compatibility. It will be '
                . 'removed in the next version of StatusNet.';
            $xs->comment($depMsg);
            $this->actor->outputTo($xs, 'activity:actor');
569 570
        }

571
        if ($this->verb != ActivityVerb::POST || count($this->objects) != 1 || $tag != 'entry') {
Zach Copley's avatar
Zach Copley committed
572
            foreach($this->objects as $object) {
573 574 575 576 577
                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
578
            }
579 580
        }

581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629
        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)) {
                $xs->element('link', array('rel' => 'ostatus:conversation',
                                           'href' => $this->context->conversation));
            }

            foreach ($this->context->attention as $attnURI) {
                $xs->element('link', array('rel' => 'ostatus:attention',
                                           'href' => $attnURI));
                $xs->element('link', array('rel' => 'mentioned',
                                           'href' => $attnURI));
            }

            // XXX: shoulda used ActivityVerb::SHARE

            if (!empty($this->context->forwardID)) {
                if (!empty($this->context->forwardUrl)) {
                    $xs->element('ostatus:forward',
                                 array('ref' => $this->context->forwardID,
                                       'href' => $this->context->forwardUrl));
                } else {
                    $xs->element('ostatus:forward',
                                 array('ref' => $this->context->forwardID));
                }
            }

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

630
        if ($this->target) {
631
            $this->target->outputTo($xs, 'activity:target');
632
        }
633

634
        foreach ($this->categories as $cat) {
635
            $cat->outputTo($xs);
636 637
        }

638
        // can be either URLs or enclosure objects
639

640 641
        foreach ($this->enclosures as $enclosure) {
            if (is_string($enclosure)) {
642 643
                $xs->element('link', array('rel' => 'enclosure',
                                           'href' => $enclosure));
644 645 646 647 648 649 650 651
            } else {
                $attributes = array('rel' => 'enclosure',
                                    'href' => $enclosure->url,
                                    'type' => $enclosure->mimetype,
                                    'length' => $enclosure->size);
                if ($enclosure->title) {
                    $attributes['title'] = $enclosure->title;
                }
652
                $xs->element('link', $attributes);
653 654 655 656 657
            }
        }

        // Info on the source feed

658
        if ($source && !empty($this->source)) {
659
            $xs->elementStart('source');
660

661 662 663 664 665 666 667 668
            $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']));
            }
669

670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687
            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);
            }
688

689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704
            $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
705

706 707 708 709 710
        foreach ($this->extra as $el) {
            list($tag, $attrs, $content) = $el;
            $xs->element($tag, $attrs, $content);
        }

711
        $xs->elementEnd($tag);
712

713
        return;
714 715
    }

716 717
    private function _child($element, $tag, $namespace=self::SPEC)
    {
718
        return ActivityUtils::child($element, $tag, $namespace);
719
    }
720

721 722 723 724 725 726 727 728
    /**
     * 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
     */
729 730 731 732 733 734
    static function iso8601Date($tm)
    {
        $dateStr = date('d F Y H:i:s', $tm);
        $d = new DateTime($dateStr, new DateTimeZone('UTC'));
        return $d->format('c');
    }
735
}