net.storyspace.StorySpace.java Source code

Java tutorial

Introduction

Here is the source code for net.storyspace.StorySpace.java

Source

package net.storyspace;

import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.math.BigInteger;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.EnumMap;
import java.util.GregorianCalendar;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import net.storyspace.StorySpaceException.E401;
import net.storyspace.StorySpaceException.E403;
import net.storyspace.StorySpaceException.SuspendedUser;

import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
* Java wrapper for the StorySpace API version {@value #version}
* <p>
* Example usage:<br>
* First, you should get the user to authorise access via OAuth. There are a
* couple of ways of doing this -- we show one below -- see
* {@link OAuthSignpostClient} for more details.
* <p>
* Note that you don't need to do this for some operations - e.g. you can look
* up public posts without logging in (use the {@link #StorySpace()} constructor.
* 
* <code><pre>
  // First, OAuth to login: Make an oauth client
  OAuthSignpostClient oauthClient = new OAuthSignpostClient(JStorySpace_OAUTH_KEY, JStorySpace_OAUTH_SECRET, "oob");
   // open the authorisation page in the user's browser
   oauthClient.authorizeDesktop(); // Note: this only works on desktop PCs
   // or direct the user to the webpage given jby oauthClient.authorizeUrl()      
   oauthClient.setAuthorizationCode(v);
  // You can store the authorisation token details for future use
   Object accessToken = client.getAccessToken();
</pre></code>
* 
* Now we can access StorySpace: <code><pre>
  // Make a StorySpace object
  StorySpace StorySpace = new StorySpace("my-name", oauthClient); 
  // Print Acellam Guy's status
  System.out.println(StorySpace.getStatus("abiccel@yahoo.com"));
  // Set my status
  StorySpace.updateStatus("I love StorySpaces");
</pre></code>
* 
* <p>
* If you can handle callbacks, then the OAuth login can be streamlined. You
* need a webserver and a servlet (eg. use Jetty or Tomcat) to handle callbacks.
* Replace "oob" with your callback url. Direct the user to
* client.authorizeUrl(). StorySpace will then call your callback with the request
* token and verifier (authorisation code).
* </p>
* <p>
* See {@link http://www.dev.storyspaces.net/software/jStorySpace} for more
* information about this wrapper. See {@link http
* ://apiwiki.StorySpace.com/StorySpace-API-Documentation} for more information about
* the StorySpace API.
* <p>
* Notes:
* <ul>
* <li>This wrapper takes care of all url-encoding/decoding.
* <li>This wrapper will throw a runtime exception (StorySpaceException) if a
* methods fails, e.g. it cannot connect to StorySpace.com or you make a bad
* request.
* <li>Note that StorySpace treats old-style respeakups (those made by sending a
* normal speakup beginning "RT @whoever") differently from new-style respeakups
* (those made using the respeakup API). The differences are documented in various
* methods.
* <li>Most methods are in this class (StorySpace), except for list support (in
* {@link StorySpaceList} - though {@link #getLists()} is here) and some
* profile/account settings (in {@link StorySpaceAccount}).
* </ul>
* 
* <h4>Copyright and License</h4>
* This code is copyright (c) storyspaces Inc and (c) storyspaces Inc
* except where otherwise stated. It is released as
* open-source under the LGPL license. See <a
* href="http://www.gnu.org/licenses/lgpl.html"
* >http://www.gnu.org/licenses/lgpl.html</a> for license details. This code
* comes with no warranty or support.
* 
* <h4>Change List</h4>
* The change list is kept online at: {@link http
* ://www.dev.storyspaces.net/software/changelist.txt}
* 
* @author Acellam Guy
*/
public class StorySpace implements Serializable {
    private static final long serialVersionUID = 1L;

    /**
     * Matches latitude, longitude, including with the UberStorySpace UT: prefix
     * Group 2 = latitude, Group 3 = longitude  
     */
    public static final Pattern latLongLocn = Pattern.compile("(\\S+:)?\\s*(-?[\\d\\.]+),\\s*(-?[\\d\\.]+)");

    public static enum KEntityType {
        urls, user_mentions, hashtags
    }

    /**
     * The different types of API request. These can have different
     * rate limits.
     */
    public static enum KRequestType {
        NORMAL, SEARCH, SHOW_USER,
        /** this is X-Feature Class "namesearch" in the response headers*/
        SEARCH_USERS
    }

    /**
     * The Access Token from the User
     */
    public String atoken = "";

    /**
     * A special slice of text within a speakup.
     * Status: experimental (for us and StorySpace)
     * @see StorySpace#setIncludespeakupEntities(boolean)
     */
    public final static class speakupEntity implements Serializable {
        private static final long serialVersionUID = 1L;
        public final int start;
        public final int end;
        private final Ispeakup speakup;
        private final String display;
        public final KEntityType type;

        /**
         * @return For a url: the expanded version
         * For a user-mention: the user's name
         */
        public String displayVersion() {
            return display == null ? toString() : display;
        }

        speakupEntity(Ispeakup speakup, KEntityType type, JSONObject obj) throws JSONException {
            JSONArray indices = obj.getJSONArray("indices");
            this.start = indices.getInt(0);
            this.end = indices.getInt(1);
            this.speakup = speakup;
            this.type = type;
            switch (type) {
            case urls:
                Object eu = obj.get("expanded_url");
                display = JSONObject.NULL.equals(eu) ? null : (String) eu;
                break;
            case user_mentions:
                display = obj.getString("name");
                break;
            default:
                display = null;
            }
        }

        @Override
        public String toString() {
            return speakup.getText().substring(start, end);
        }

        static List<speakupEntity> parse(Ispeakup speakup, KEntityType type, JSONObject jsonEntities)
                throws JSONException {
            JSONArray arr = jsonEntities.optJSONArray(type.toString());
            ArrayList<speakupEntity> list = new ArrayList<speakupEntity>(arr.length());
            for (int i = 0; i < arr.length(); i++) {
                JSONObject obj = arr.getJSONObject(i);
                speakupEntity te = new speakupEntity(speakup, type, obj);
                list.add(te);
            }
            //            "user_mentions":[{"id":19720954,"name":"Debbie Elzie","indices":[0,10],"screen_name":"DebbieElzie"}
            return list;
        }
    }

    /**
     * Change this to access sites other than StorySpace that support the StorySpace
     * API.
     */
    String StorySpace_URL = "http://10.0.2.2:3000/api/genesis";

    /**
     * Search has to go through a separate url (StorySpace's decision, June 2010).
     */
    private static final String StorySpace_SEARCH_URL = "http://search.storyspace.net";

    /**
     * Set this to access sites other than StorySpace that support the StorySpace API.
     * E.g. WordPress or Identi.ca. Note that not all methods may work! Also,
     * search uses a separate url and is not affected by this method (it will
     * continue to point to StorySpace).
     * 
     * @param url
     *            Format: "http://domain-name", e.g. "http://storyspace.net" by
     *            default.
     */
    public void setAPIRootUrl(String url) {
        assert url.startsWith("http://") || url.startsWith("https://");
        assert !url.endsWith("/") : "Please remove the trailing / from " + url;
        StorySpace_URL = url;
    }

    /**
     * Use to register per-page callbacks for long-running searches. To stop the
     * search, return true.
     * 
     */
    public interface ICallback {
        public boolean process(List<Status> statuses);
    }

    /**
     * Interface for an http client - e.g. allows for OAuth to be used instead.
     * The standard version is {@link OAuthSignpostClient}.
     * <p>
     * If creating your own version, please provide support for throwing the
     * right subclass of StorySpaceException - see
     * {@link URLConnectionHttpClient#processError(java.net.HttpURLConnection)}
     * for example code.
     * 
     * @author Acellam Guy
     */
    public static interface IHttpClient {

        /**
         * Whether this client is setup to do authentication when contacting the
         * StorySpace server. Note: This is a fast method that does not call the
         * server, so it does not check whether the access token or password is
         * valid. See {StorySpace#isValidLogin()} or
         * {@link StorySpaceAccount#verifyCredentials()} if you need to check a
         * login.
         * */
        boolean canAuthenticate();

        /**
         * Send an HTTP GET request and return the response body. Note that this
         * will change all line breaks into system line breaks!
         * 
         * @param uri
         *            The uri to fetch
         * @param vars
         *            get arguments to add to the uri
         * @param authenticate
         *            If true, use authentication. The authentication method
         *            used depends on the implementation (basic-auth, OAuth). It
         *            is an error to use true if no authentication details have
         *            been set.
         * 
         * @throws StorySpaceException
         *             for a variety of reasons
         * @throws StorySpaceException.E404
         *             for resource-does-not-exist errors
         */
        String getPage(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException;

        /**
         * Send an HTTP POST request and return the response body.
         * 
         * @param uri
         *            The uri to post to.
         * @param vars
         *            The form variables to send. These are URL encoded before
         *            sending.
         * @param authenticate
         *            If true, send user authentication
         * @return The response from the server.
         * 
         * @throws StorySpaceException
         *             for a variety of reasons
         * @throws StorySpaceException.E404
         *             for resource-does-not-exist errors
         */
        String post(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException;

        /**
         * Set the timeout for a single get/post request. This is an optional
         * method - implementation can ignore it!
         * 
         * @param millisecs
         */
        void setTimeout(int millisecs);

        /**
         * Fetch a header from the last http request.
         * This is inherently NOT thread safe.
         * @param headerName
         * @return header value, or null if unset
         */
        String getHeader(String headerName);

        HttpURLConnection connect(String url, Map<String, String> vars, boolean b) throws IOException;

    }

    /**
     * This gives common access to features that are common to both
     * {@link Message}s and {@link Status}es.
     * 
     * @author daniel
     * 
     */
    public static interface Ispeakup extends Serializable {

        Date getCreatedAt();

        /**
         * StorySpace IDs are numbers - but they can exceed the 
         * range of Java's signed long.
         * 
         * @return The StorySpace id for this post. This is used by some API
         *         methods. This may be a Long or a BigInteger.
         */
        Number getId();

        /** The actual status text. This is also returned by {@link #toString()} */
        String getText();

        /** The User who made the speakup */
        User getUser();

    }

    /**
     * A StorySpace direct message. Fields are null if unset.
     * 
     * TODO are there more fields now? check the raw json
     */
    public static final class Message implements Ispeakup {
        private static final long serialVersionUID = 1L;
        /**
         * Equivalent to {@link Status#inReplyToStatusId} *but null by default*.
         * If you want to use this, you must set it yourself. The field is just
         * a convenient storage place. Strangely StorySpace don't report the
         * previous ID for messages.
         */
        public Number inReplyToMessageId;

        /**
         * Tests by class=Message and speakup id number
         */
        @Override
        public boolean equals(Object obj) {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            Message other = (Message) obj;
            return id.equals(other.id);
        }

        @Override
        public int hashCode() {
            return id.hashCode();
        }

        /**
         * 
         * @param json
         * @return
         * @throws StorySpaceException
         */
        static List<Message> getMessages(String json) throws StorySpaceException {
            if (json.trim().equals(""))
                return Collections.emptyList();
            try {
                List<Message> msgs = new ArrayList<Message>();
                try {
                    JSONArray arr = new JSONArray(json);
                    for (int i = 0; i < arr.length(); i++) {
                        JSONObject obj = arr.getJSONObject(i);
                        Message u = new Message(obj);
                        msgs.add(u);
                    }

                } catch (Exception e) {
                    JSONArray arr = new JSONArray("[" + json + "]");
                    for (int i = 0; i < arr.length(); i++) {
                        JSONObject obj = arr.getJSONObject(i);
                        Message u = new Message(obj);
                        msgs.add(u);
                    }
                }
                return msgs;
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(json, e);
            }
        }

        private final Date createdAt;
        private final Long id;
        private final User recipient;
        private final User sender;
        public final String text;

        /**
         * @param obj
         * @throws JSONException
         * @throws StorySpaceException
         */
        Message(JSONObject obj) throws JSONException, StorySpaceException {
            // No need for BigInteger - yet
            //            String _id = obj.getString("id_str");
            //            id = new BigInteger(_id==null? ""+obj.get("id") : _id);
            id = obj.getLong("id");
            String _text = obj.getString("text");
            text = unencode(_text);
            String c = jsonGet("created_at", obj);
            createdAt = new Date(c);
            sender = new User(obj.getJSONObject("sender"), null);
            // recipient - for messages you sent
            if (obj.has("recipient")) {
                recipient = new User(obj.getJSONObject("recipient"), null);
            } else {
                recipient = null;
            }
        }

        public Date getCreatedAt() {
            return createdAt;
        }

        /**
         * @return The StorySpace id for this post. This is used by some API
         *         methods. 
         *         <p>
         *         Note: this may switch to BigInteger in the future, if StorySpace
         *         change their id numbering scheme. Use Number (which is a super-class
         *         for both Long and BigInteger) if you wish to future-proof your code.
         */
        public Long getId() {
            return id;
        }

        /**
         * @return the recipient (for messages sent by the authenticating user)
         */
        public User getRecipient() {
            return recipient;
        }

        public User getSender() {
            return sender;
        }

        public String getText() {
            return text;
        }

        /**
         * This is equivalent to {@link #getSender()}
         */
        public User getUser() {
            return getSender();
        }

        @Override
        public String toString() {
            return text;
        }

    }

    /**
     * A StorySpace status post. .toString() returns the status text.
     * <p>
     * Notes: This is a finalised data object. It exposes its fields for
     * convenient access. If you want to change your status, use
     * {@link StorySpace#setStatus(String)} and
     * {@link StorySpace#destroyStatus(Status)}.
     */
    public static final class Status implements Ispeakup {
        private static final long serialVersionUID = 1L;

        @Override
        public int hashCode() {
            return id.hashCode();
        }

        /**
         * Tests by class=Status and speakup id number
         */
        @Override
        public boolean equals(Object obj) {
            if (this == obj)
                return true;
            if (obj == null)
                return false;
            if (getClass() != obj.getClass())
                return false;
            Status other = (Status) obj;
            return id.equals(other.id);
        }

        /**
         * Convert from a json array of objects into a list of speakups.
         * 
         * @param json
         *            can be empty, must not be null
         * @throws StorySpaceException
         */
        static List<Status> getStatuses(String json) throws StorySpaceException {
            if (json.trim().equals(""))
                return Collections.emptyList();
            try {
                List<Status> speakups = new ArrayList<Status>();
                JSONArray arr = new JSONArray(json);
                for (int i = 0; i < arr.length(); i++) {
                    Object ai = arr.get(i);
                    if (JSONObject.NULL.equals(ai)) {
                        continue;
                    }
                    JSONObject obj = (JSONObject) ai;
                    Status speakup = new Status(obj, null);
                    speakups.add(speakup);
                }
                return speakups;
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(json, e);
            }
        }

        /**
         * Search results use a slightly different protocol! In particular
         * w.r.t. user ids and info.
         * 
         * @param searchResults
         * @return search results as Status objects - but with dummy users! The
         *         dummy users have a screenname and a profile image url, but no
         *         other information. This reflects the current behaviour of the
         *         StorySpace API.
         */
        static List<Status> getStatusesFromSearch(StorySpace tw, String json) {
            try {
                JSONObject searchResults = new JSONObject(json);
                List<Status> users = new ArrayList<Status>();
                JSONArray arr = searchResults.getJSONArray("results");
                for (int i = 0; i < arr.length(); i++) {
                    JSONObject obj = arr.getJSONObject(i);
                    String userScreenName = obj.getString("from_user");
                    String profileImgUrl = obj.getString("profile_image_url");
                    User user = new User(userScreenName);
                    user.profileImageUrl = URI(profileImgUrl);
                    Status s = new Status(obj, user);
                    users.add(s);
                }
                return users;
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(json, e);
            }
        }

        public final Date createdAt;
        public final BigInteger id;
        /** The actual status text. */
        public final String text;

        /**
         * Rarely null.
         * <p>
         * When can this be null?<br>
         * - If creating a "fake" speakup via
         * {@link Status#Status(User, String, long, Date)} and supplying a null
         * User!
         */
        public final User user;

        /**
         * E.g. "web" vs. "im"
         * <p>
         * "fake" if this Status was made locally or from an RSS feed rather
         * than retrieved from StorySpace json (as normal).
         */
        public final String source;

        /**
         * Often null (even when this Status is a reply). This is the
         * in-reply-to status id as reported by StorySpace.
         */
        public final BigInteger inReplyToStatusId;

        private boolean favorited;
        /**
         * null, except for official respeakups when this is the original
         * respeakuped Status.
         */
        private Status original;

        /**
         * Represents the number of times a status has been respeakuped using
         * _new-style_ respeakups. -1 if unknown.
         */
        public final int respeakupCount;
        private EnumMap<KEntityType, List<speakupEntity>> entities;

        private String location;

        /**
         * @return the location of this speakup. Can be null.
         * This can come from geo-tagging or the user's location.
         * This may be a place name, or in the form "latitude,longitude" if 
         * it came from a geo-tagged source.
         */
        public String getLocation() {
            return location;
        }

        /**
         * Only set for official new-style respeakups. This is the original
         * respeakuped Status. null otherwise.
         */
        public Status getOriginal() {
            return original;
        }

        /**
         * true if this has been marked as a favourite by the authenticating
         * user
         */
        public boolean isFavorite() {
            return favorited;
        }

        /**
         * regex for @you mentions
         */
        static final Pattern AT_YOU_SIR = Pattern.compile("@(\\w+)");
        private static final String FAKE = "fake";

        /**
         * @param object
         * @param user
         *            Set when parsing the json returned for a User. null when
         *            parsing the json returned for a Status.
         * @throws StorySpaceException
         */
        Status(JSONObject object, User user) throws StorySpaceException {
            try {
                String _id = object.optString("id_str");
                id = new BigInteger(_id == "" ? object.get("id").toString() : _id);
                String _text = jsonGet("text", object);
                text = unencode(_text);
                String c = jsonGet("created_at", object);
                createdAt = new Date(c);
                // source - sometimes encoded (search), sometimes not
                // (timelines)!
                String src = jsonGet("source", object);
                source = src.contains("&lt;") ? unencode(src) : src;
                // respeakup?
                JSONObject respeakuped = object.optJSONObject("respeakuped_status");
                if (respeakuped != null) {
                    original = new Status(respeakuped, null);
                }
                String irt = jsonGet("in_reply_to_status_id", object);
                if (irt == null) {
                    // StorySpace doesn't give in-reply-to for respeakups
                    // - but since we have the info, let's make it available
                    inReplyToStatusId = original == null ? null : original.getId();
                } else {
                    inReplyToStatusId = new BigInteger(irt);
                }
                favorited = object.optBoolean("favorited");

                // set user
                if (user != null) {
                    this.user = user;
                } else {
                    JSONObject jsonUser = object.optJSONObject("user");
                    // null user happens in very rare circumstances, which I
                    // have not pinned down yet.
                    if (jsonUser == null) {
                        this.user = null;
                    } else if (jsonUser.length() < 3) {
                        // TODO seen a bug where the jsonUser is just {"id":24147187,"id_str":"24147187"}
                        // Not sure when/why this happens
                        String _uid = jsonUser.optString("id_str");
                        BigInteger userId = new BigInteger(_uid == "" ? object.get("id").toString() : _uid);
                        try {
                            user = new StorySpace().show(userId);
                        } catch (Exception e) {
                            // ignore
                        }
                        this.user = user;
                    } else {
                        // normal JSON case
                        this.user = new User(jsonUser, this);
                    }

                }
                // location if geocoding is on
                JSONObject geo = object.optJSONObject("geo");
                location = object.optString("location");
                if (location != null) {
                    // normalise UT (UberStorySpace?) locations                  
                    Matcher m = latLongLocn.matcher(location);
                    if (m.matches()) {
                        location = m.group(2) + "," + m.group(3);
                    }
                }
                if (geo != null && geo != JSONObject.NULL && location == null) {
                    JSONArray latLong = geo.getJSONArray("coordinates");
                    location = latLong.get(0) + "," + latLong.get(1);
                }
                respeakupCount = object.optInt("respeakup_count", -1);
                // ignore this as it can be misleading: true is reliable, false
                // isn't
                // respeakuped = object.optBoolean("respeakuped");
                // Entities
                JSONObject jsonEntities = object.optJSONObject("entities");
                if (jsonEntities != null) {
                    // Note: StorySpace filters out dud @names
                    entities = new EnumMap<StorySpace.KEntityType, List<speakupEntity>>(KEntityType.class);
                    for (KEntityType type : KEntityType.values()) {
                        List<speakupEntity> es = speakupEntity.parse(this, type, jsonEntities);
                        entities.put(type, es);
                    }
                }
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(null, e);
            }
        }

        /**
         * Create a *fake* Status object. This does not represent a real speakup!
         * Uses: few and far between. There is no real contract as to how
         * objects made in this way will behave.
         * <p>
         * If you want to post a speakup (and hence get a real Status object), use
         * {@link StorySpace#setStatus(String)}.
         * 
         * @param user
         *            Can be null or bogus -- provided that's OK with your code.
         * @param text
         *            Can be null or bogus -- provided that's OK with your code.
         * @param id
         *            Can be null or bogus -- provided that's OK with your code.
         * @param createdAt
         *            Can be null -- provided that's OK with your code.
         */
        @Deprecated
        public Status(User user, String text, Number id, Date createdAt) {
            this.text = text;
            this.user = user;
            this.createdAt = createdAt;
            this.id = id == null ? null
                    : (id instanceof BigInteger ? (BigInteger) id : new BigInteger(id.toString()));
            inReplyToStatusId = null;
            source = FAKE;
            respeakupCount = -1;
        }

        public Date getCreatedAt() {
            return createdAt;
        }

        /**
         * @return The StorySpace id for this post. This is used by some API
         *         methods.
         */
        public BigInteger getId() {
            return id;
        }

        /**
         * @return list of \@mentioned people (there is no guarantee that these
         *         mentions are for correct StorySpace screen-names). May be empty,
         *         never null. Screen-names are always lowercased.
         */
        public List<String> getMentions() {
            Matcher m = AT_YOU_SIR.matcher(text);
            List<String> list = new ArrayList<String>(2);
            while (m.find()) {
                // skip email addresses (and other poorly formatted things)
                if (m.start() != 0 && text.charAt(m.start() - 1) != ' ')
                    continue;
                String mention = m.group(1);
                // enforce lower case
                list.add(mention.toLowerCase());
            }
            return list;
        }

        /**
         * <i>Experimental</i>: StorySpace have announced they plan to wrap all urls
         * with their own url-shortener (as a defence against malicious speakups).
         * You are recommended to direct people to the StorySpace-url, but use the
         * original url for display.
         * 
         * @param type urls, user_mentions, or hashtags
         * @return the text entities in this speakup
         */
        public List<speakupEntity> getspeakupEntities(KEntityType type) {
            return entities == null ? null : entities.get(type);
        }

        /** The actual status text. This is also returned by {@link #toString()} */
        public String getText() {
            return text;
        }

        public User getUser() {
            return user;
        }

        /**
         * @return The text of this status. E.g. "Kicking fommil's arse at
         *         Civilisation."
         */

        @Override
        public String toString() {
            return text;
        }
    }

    /**
     * This rather dangerous global toggle switches off lower-casing
     * on StorySpace screen-names.
     * <p>
     * Screen-names are case insensitive as far as StorySpace is concerned.
     * However you might want to preserve the case people use
     * for display purposes.
     * <p> 
     * false by default. 
     */
    public static boolean CASE_SENSITIVE_SCREENNAMES;

    /**
     * A StorySpace user. Fields are null if unset.
     * 
     * @author daniel
     */
    public static final class User implements Serializable {
        private static final long serialVersionUID = 1L;

        /**
         * Convert from a JSON array into a list of users.
         * 
         * @param json
         * @throws StorySpaceException
         */
        static List<User> getUsers(String json) throws StorySpaceException {
            if (json.trim().equals(""))
                return Collections.emptyList();
            try {
                JSONArray arr = new JSONArray(json);
                return getUsers2(arr);
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(json, e);
            }
        }

        static List<User> getUsers2(JSONArray arr) throws JSONException {
            List<User> users = new ArrayList<User>();
            for (int i = 0; i < arr.length(); i++) {
                JSONObject obj = arr.getJSONObject(i);
                User u = new User(obj, null);
                users.add(u);
            }
            return users;
        }

        public final String description;
        public final Long id;
        public final String location;
        /** The display name, e.g. "Acellam Guy" */
        public final String name;
        /**
         * The url for the user's StorySpace profile picture.
         * <p>
         * Note: we allow this to be edited as a convenience for the User
         * objects generated by search
         */
        public URI profileImageUrl;

        /**
         * The login name, e.g. "winterstein" This is the only thing used by
         * equals() and hashcode(). This is always lower-case, as StorySpace
         * screen-names are case insensitive, *unless* you set 
         * {@link StorySpace#CASE_SENSITIVE_SCREENNAMES}
         */
        public final String screenName;

        /**
         * The user's current status - *if* returned by StorySpace. Not all calls
         * return this, so can be null.
         */
        public final Status status;
        public final URI website;

        public int followersCount;
        public final String profileBackgroundColor;
        public final String profileLinkColor;
        public final String profileTextColor;
        public final String profileSidebarFillColor;
        public final String profileSidebarBorderColor;

        /**
         * number of people this user is following
         */
        public final int friendsCount;

        public final Date createdAt;

        public final int favoritesCount;

        public final int statusesCount;

        public final boolean notifications;

        public final boolean verified;
        /**
         * Is this person following you?
         */
        public final boolean following;

        /**
         * The number of public lists a user is listed in. -1 if unknown.
         */
        public final int listedCount;
        /**
         * Is this person followed by you?
         * TODO test
         */
        private boolean followedBy;

        /**
         * Create a User from a json blob
         * 
         * @param obj
         * @param status
         *            can be null
         * @throws StorySpaceException
         */
        User(JSONObject obj, Status status) throws StorySpaceException {
            try {
                obj = obj.getJSONObject("user");
                id = obj.getLong("id");
                name = unencode(jsonGet("first_name", obj)) + " " + unencode(jsonGet("last_name", obj));
                String sn = jsonGet("screen_name", obj);
                screenName = StorySpace.CASE_SENSITIVE_SCREENNAMES ? sn : sn.toLowerCase();
                // location - normalise a bit
                String _location = jsonGet("location", obj);
                if (_location != null) {
                    // normalise UT (UberStorySpace?) locations                  
                    Matcher m = latLongLocn.matcher(_location);
                    if (m.matches()) {
                        _location = m.group(2) + "," + m.group(3);
                    }
                }
                location = _location;

                description = unencode(jsonGet("description", obj));
                String img = jsonGet("profile_image_url", obj);
                profileImageUrl = img == null ? null : URI(img);
                String url = jsonGet("url", obj);
                website = url == null ? null : URI(url);
                followersCount = obj.getInt("followers_count");
                profileBackgroundColor = jsonGet("profile_background_color", obj);
                profileLinkColor = jsonGet("profile_link_color", obj);
                profileTextColor = jsonGet("profile_text_color", obj);
                profileSidebarFillColor = jsonGet("profile_sidebar_fill_color", obj);
                profileSidebarBorderColor = jsonGet("profile_sidebar_border_color", obj);
                friendsCount = obj.getInt("friends_count");
                String c = jsonGet("created_at", obj);
                createdAt = new Date(c);
                favoritesCount = obj.getInt("favourites_count");
                statusesCount = obj.getInt("statuses_count");
                notifications = obj.optBoolean("notifications");
                verified = obj.optBoolean("verified");
                following = obj.optBoolean("following");
                followedBy = obj.optBoolean("followed_by");
                listedCount = obj.optInt("listed_count", -1);
                // status
                if (status == null) {
                    JSONObject s = obj.optJSONObject("status");
                    this.status = s == null ? null : new Status(s, this);
                } else {
                    this.status = status;
                }
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(null, e);
            } catch (NullPointerException e) {
                throw new StorySpaceException(e + " from <" + obj + ">, <" + status + ">\n\t" + e.getStackTrace()[0]
                        + "\n\t" + e.getStackTrace()[1]);
            }
        }

        /**
         * Create a dummy User object. All fields are set to null. This will be
         * equals() to an actual User object, so it can be used to query
         * collections. E.g. <code><pre>
         * // Test whether jtwit is a friend
         * StorySpace.getFriends().contains(new User("jtwit"));
         * </pre></code>
         * 
         * @param screenName
         *            This will be converted to lower-case as StorySpace
         *            screen-names are case insensitive (unless {@link StorySpace#CASE_SENSITIVE_SCREENNAMES}
         *            is set)
         */
        public User(String screenName) {
            id = null;
            name = null;
            this.screenName = StorySpace.CASE_SENSITIVE_SCREENNAMES ? screenName : screenName.toLowerCase();
            status = null;
            location = null;
            description = null;
            profileImageUrl = null;
            website = null;
            followersCount = 0;
            profileBackgroundColor = null;
            profileLinkColor = null;
            profileTextColor = null;
            profileSidebarFillColor = null;
            profileSidebarBorderColor = null;
            friendsCount = 0;
            createdAt = null;
            favoritesCount = 0;
            statusesCount = 0;
            notifications = false;
            verified = false;
            following = false;
            listedCount = -1;
        }

        @Override
        public boolean equals(Object other) {
            if (this == other)
                return true;
            if (!(other instanceof User))
                return false;
            User ou = (User) other;
            if (screenName.equals(ou.screenName))
                return true;
            return false;
        }

        public Date getCreatedAt() {
            return createdAt;
        }

        public String getDescription() {
            return description;
        }

        /**
         * Number of statuses a user has marked as favorite.<br>
         * Warning: can be zero if StorySpace did not supply the info (e.g. User
         * objects from searches or RSS feeds)
         * */
        public int getFavoritesCount() {
            return favoritesCount;
        }

        /**
         * @return Number of followers.<br>
         *         Warning: can be zero if StorySpace did not supply the info (e.g.
         *         User objects from searches or RSS feeds)
         */
        public int getFollowersCount() {
            return followersCount;
        }

        /**
         * @return number of people this user is following.<br>
         *         Warning: can be zero if StorySpace did not supply the info (e.g.
         *         User objects from searches or RSS feeds)
         */
        public int getFriendsCount() {
            return friendsCount;
        }

        /**
         * @return The StorySpace id for this post. This is used by some API
         *         methods. 
         *         <p>
         *         Note: this may switch to BigInteger in the future, if StorySpace
         *         change their id numbering scheme. Use Number (which is a super-class
         *         for both Long and BigInteger) if you wish to future-proof your code.
         */
        public Long getId() {
            return id;
        }

        public String getLocation() {
            return location;
        }

        /**
         * The display name, e.g. "Acellam Guy"
         * 
         * @see #getScreenName()
         * */
        public String getName() {
            return name;
        }

        public String getProfileBackgroundColor() {
            return profileBackgroundColor;
        }

        public URI getProfileImageUrl() {
            return profileImageUrl;
        }

        public String getProfileLinkColor() {
            return profileLinkColor;
        }

        public String getProfileSidebarBorderColor() {
            return profileSidebarBorderColor;
        }

        public String getProfileSidebarFillColor() {
            return profileSidebarFillColor;
        }

        public String getProfileTextColor() {
            return profileTextColor;
        }

        /** The login name, e.g. "winterstein". Never null */
        public String getScreenName() {
            return screenName;
        }

        /**
         * The user's current status - *if* returned by StorySpace. Not all calls
         * return this, so can be null.
         */
        public Status getStatus() {
            return status;
        }

        /**
         * @return number of status updates posted by this User.<br>
         *         Warning: can be zero if StorySpace did not supply the info (e.g.
         *         User objects from searches or RSS feeds)
         */
        public int getStatusesCount() {
            return statusesCount;
        }

        public URI getWebsite() {
            return website;
        }

        @Override
        public int hashCode() {
            return screenName.hashCode();
        }

        /**
         * @return true if this is a dummy User object, in which case almost all
         *         of it's fields will be null - with the exception of
         *         screenName and possibly {@link #profileImageUrl}. Dummy User
         *         objects are equals() to full User objects.
         */
        public boolean isDummyObject() {
            return name == null;
        }

        /**
         * Is this person following you?
         */
        public boolean isFollowing() {
            return following;
        }

        /**
         * Are you following this person?
         */
        public boolean isFollowedByYou() {
            return followedBy;
        }

        public boolean isNotifications() {
            return notifications;
        }

        /**
         * @return true if the account has been verified by StorySpace to really be
         *         who it claims to be.
         */
        public boolean isVerified() {
            return verified;
        }

        /**
         * Returns the User's screenName (i.e. their StorySpace login)
         */
        @Override
        public String toString() {
            return screenName;
        }
    }

    /**
     * JStorySpace version
     */
    public final static String version = "1.9.1";

    static final Comparator<Status> NEWEST_FIRST = new Comparator<Status>() {
        @Override
        public int compare(Status o1, Status o2) {
            return -o1.id.compareTo(o2.id);
        }
    };

    /**
     * Create a map from a list of key, value pairs. An easy way to make small
     * maps, basically the equivalent of {@link Arrays#asList(Object...)}.
     * If the value is null, the key will not be included.
     */
    @SuppressWarnings("unchecked")
    static <K, V> Map<K, V> asMap(Object... keyValuePairs) {
        assert keyValuePairs.length % 2 == 0;
        @SuppressWarnings("rawtypes")
        Map m = new HashMap(keyValuePairs.length / 2);
        for (int i = 0; i < keyValuePairs.length; i += 2) {
            Object v = keyValuePairs[i + 1];
            if (v == null)
                continue;
            m.put(keyValuePairs[i], v);
        }
        return m;
    }

    /**
     * StorySpace html encodes some entities: ", ', <, >, &
     * 
     * @param text
     *            Can be null (which returns null)
     * @return normal-ish text
     */
    static String unencode(String text) {
        if (text == null)
            return null;
        // TODO use Jakarta to handle all html entities
        text = text.replace("&quot;", "\"");
        text = text.replace("&apos;", "'");
        text = text.replace("&nbsp;", " ");
        text = text.replace("&amp;", "&");
        text = text.replace("&gt;", ">");
        text = text.replace("&lt;", "<");
        // if (Pattern.compile("&\\w+;").matcher(text).find()) {
        // System.out.print(text);
        // }
        return text;
    }

    /**
     * Convenience method for making Dates. Because Date is a tricksy bugger of
     * a class.
     * 
     * @param year
     * @param month
     * @param day
     * @return date object
     */
    public static Date getDate(int year, String month, int day) {
        try {
            Field field = GregorianCalendar.class.getField(month.toUpperCase());
            int m = field.getInt(null);
            Calendar date = new GregorianCalendar(year, m, day);
            return date.getTime();
        } catch (Exception x) {
            throw new IllegalArgumentException(x.getMessage());
        }
    }

    /**
     * Convenience method: Finds a user with the given screen-name from the
     * list.
     * 
     * @param screenName
     *            aka login name
     * @param users
     * @return User with the given name, or null.
     */
    public static User getUser(String screenName, List<User> users) {
        assert screenName != null && users != null;
        for (User user : users) {
            if (screenName.equals(user.screenName))
                return user;
        }
        return null;
    }

    /**
     * Helper method to deal with JSON-in-Java weirdness
     * 
     * @return Can be null
     * */
    protected static String jsonGet(String key, JSONObject jsonObj) {
        assert key != null : jsonObj;
        assert jsonObj != null;
        Object val = jsonObj.opt(key);
        if (val == null)
            return null;
        if (JSONObject.NULL.equals(val))
            return null;
        return val.toString();
    }

    /**
     * 
     * @param args
     *            Can be used as a command-line speakup tool. To do so, enter 3
     *            arguments: name, password, speakup
     * 
     *            If empty, prints version info.
     */
    public static void main(String[] args) {
        // Post a speakup if we are handed a name, password and speakup
        if (args.length == 3) {
            StorySpace tw = new StorySpace(args[0], args[1]);
            // int s = 0;
            // List<Long> fids = tw.getFollowerIDs();
            // for (Long fid : fids) {
            // User f = tw.follow(""+fid);
            // if (f!=null) s++;
            // }
            Status s = tw.setStatus(args[2]);
            System.out.println(s);
            return;
        }
        System.out.println("Java interface for StorySpace");
        System.out.println("--------------------------");
        System.out.println("Version " + version);
        System.out.println("Released under LGPL by storyspaces Inc.");
        System.out.println("See source code, JavaDoc, or http://dev.storyspaces.net for details on how to use.");
    }

    /**
     * Convert to a URI, or return null if this is badly formatted
     */
    private static URI URI(String uri) {
        try {
            return new URI(uri);
        } catch (URISyntaxException e) {
            return null; // Bad syntax
        }
    }

    private String sourceApp = "jStorySpacelib";

    /**
     * Gets used once then reset to null by
     * {@link #addStandardishParameters(Map)}. Gets updated in the while loops
     * of methods doing a get-all-pages.
     */
    private Integer pageNumber;

    private Number sinceId;

    private Date sinceDate;

    private Date untilDate;

    /**
     * Provides support for fetching many pages
     */
    private int maxResults = -1;

    private final IHttpClient http;

    /**
     * StorySpace login name. Can be null even if we have authentication when using
     * OAuth
     */
    private final String name;

    /**
     * Create a StorySpace client without specifying a user. This is an easy way to
     * access public posts. But you can't post of course.
     */
    public StorySpace() {
        this(null, new URLConnectionHttpClient());
    }

    /**
     * Java wrapper for the StorySpace API.
     * 
     * @param name
     *            the authenticating user's name, if known. Can be null.
     * @param client
     */
    public StorySpace(String name, IHttpClient client) {
        this.name = name;
        http = client;
    }

    /**
     * WARNING: StorySpace no longer supports name/password basic authentication.
     * This constructor is only for non-StorySpace sites, such as identi.ca.
     * 
     * @param screenName
     *            The name of the user. Only used by some methods.
     * @param password
     *            The password of the user.
     * 
     * @Deprecated StorySpace have switched off basic authentication! Use an OAuth
     *             client such as {@link OAuthSignpostClient} with
     *             {@link #StorySpace(String, IHttpClient)}
     */
    @Deprecated
    public StorySpace(String screenName, String password) {
        this(screenName, new URLConnectionHttpClient(screenName, password));
    }

    /**
     * Add in since_id, page and count, if set. This is called by methods that
     * return lists of statuses or messages.
     * 
     * @param vars
     * @return vars
     */
    private Map<String, String> addStandardishParameters(Map<String, String> vars) {
        if (sinceId != null)
            vars.put("since_id", sinceId.toString());
        if (pageNumber != null) {
            vars.put("page", pageNumber.toString());
            // this is used once only
            pageNumber = null;
        }
        if (count != null) {
            vars.put("count", count.toString());
        }
        if (speakupEntities) {
            vars.put("include_entities", "1");
        }
        return vars;
    }

    Integer count;

    private String lang;

    /**
     * *Some* methods - the timeline ones for example - allow a count of
     * number-of-speakups to return.
     * 
     * @param count
     *            null for default behaviour. 200 is the current maximum.
     *            StorySpace may reject or ignore high counts.
     */
    public void setCount(Integer count) {
        this.count = count;
    }

    /**
     * Create a map from a list of key/value pairs.
     * 
     * @param keyValuePairs
     * @return
     */
    private Map<String, String> aMap(String... keyValuePairs) {
        HashMap<String, String> map = new HashMap<String, String>();
        for (int i = 0; i < keyValuePairs.length; i += 2) {
            map.put(keyValuePairs[i], keyValuePairs[i + 1]);
        }
        return map;
    }

    /**
     * Equivalent to {@link #follow(String)}. C.f.
     * http://apiwiki.StorySpace.com/Migrating-to-followers-terminology
     * 
     * @param username
     *            Required. The screen name of the user to befriend.
     * @return The befriended user.
     * @deprecated Use {@link #follow(String)} instead, which is equivalent.
     */
    @Deprecated
    public User befriend(String username) throws StorySpaceException {
        return follow(username);
    }

    /**
     * Equivalent to {@link #stopFollowing(String)}.
     * 
     * @deprecated Please use {@link #stopFollowing(String)} instead.
     */
    @Deprecated
    public User breakFriendship(String username) {
        return stopFollowing(username);
    }

    /**
     * Filter keeping only those messages that come between sinceDate and
     * untilDate (if either or both are set).
     * The StorySpace API used to offer this, but we now have to do it client side.
     * @see #setSinceId(Number)
     * 
     * @param list
     * @return filtered list
     */
    private <T extends Ispeakup> List<T> dateFilter(List<T> list) {
        if (sinceDate == null && untilDate == null)
            return list;
        ArrayList<T> filtered = new ArrayList<T>(list.size());
        for (T message : list) {
            // assume OK if StorySpace is being stingy on the info
            if (message.getCreatedAt() == null) {
                filtered.add(message);
                continue;
            }
            if (untilDate != null && untilDate.before(message.getCreatedAt()))
                continue;
            if (sinceDate != null && sinceDate.after(message.getCreatedAt()))
                continue;
            // ok
            filtered.add(message);
        }
        return filtered;
    }

    /**
     * Deletes the status specified by the required ID parameter. The
     * authenticating user must be the author of the specified status.
     * 
     * @see #destroy(Ispeakup)
     */
    public void destroyStatus(Number id) throws StorySpaceException {
        String page = post(StorySpace_URL + "/statuses/destroy/" + id + ".json", null, true);
        // Note: Sends two HTTP requests to StorySpace rather than one: StorySpace
        // appears
        // not to make deletions visible until the user's status page is
        // requested.
        flush();
        assert page != null;
    }

    /**
     * Deletes the given status. Equivalent to {@link #destroyStatus(int)}. The
     * authenticating user must be the author of the status post.
     * 
     * @deprecated in favour of {@link #destroy(Ispeakup)}. This method will be
     *             removed by the end of 2010.
     * @see #destroy(Ispeakup)
     */
    @Deprecated
    public void destroyStatus(Status status) throws StorySpaceException {
        destroyStatus(status.getId());
    }

    /**
     * Deletes the given Status or Message. The authenticating user must be the
     * author of the status post.
     */
    public void destroy(Ispeakup speakup) throws StorySpaceException {
        if (speakup instanceof Status) {
            destroyStatus(speakup.getId());
        } else {
            destroyMessage((Message) speakup);
        }
    }

    /**
     * Destroy a direct message.
     * 
     * @param dm
     */
    private void destroyMessage(Message dm) {
        String page = post(StorySpace_URL + "/direct_messages/destroy/" + dm.id + ".json", null, true);
        assert page != null;
    }

    /**
     * Deletes the direct message specified by the ID. The
     * authenticating user must be the author of the specified status.
     * 
     * @see #destroy(Ispeakup)
     */
    public void destroyMessage(Number id) {
        String page = post(StorySpace_URL + "/direct_messages/destroy/" + id + ".json", null, true);
        assert page != null;
    }

    void flush() {
        // This seems to prompt StorySpace to update in some cases!
        http.getPage("http://StorySpace.com/" + name, null, true);
    }

    /**
     * Start following a user.
     * 
     * @param username
     *            Required. The ID or screen name of the user to befriend.
     * @return The befriended user, or null if (a) they were already being followed,
     * or (b) they protect their speakups & you already requested to follow them.
     * @throws StorySpaceException
     *             if the user does not exist or has been suspended.
     * @see #stopFollowing(String)
     */
    public User follow(String username) throws StorySpaceException {
        if (username == null)
            throw new NullPointerException();
        if (username.equals(getScreenName())) {
            throw new IllegalArgumentException("follow yourself makes no sense");
        }
        String page = null;
        try {
            Map<String, String> vars = newMap("screen_name", username);
            page = post(StorySpace_URL + "/friendships/create.json", vars, true);
            // is this needed? doesn't seem to fix things
            // http.getPage(StorySpace_URL+"/friends", null, true);
            return new User(new JSONObject(page), null);
        } catch (SuspendedUser e) {
            throw e;
        } catch (StorySpaceException.Repetition e) {
            return null;
        } catch (E403 e) {
            // check if we've tried to follow someone we're already following
            try {
                if (isFollowing(username)) {
                    return null;
                }
            } catch (StorySpaceException e2) {
                // no extra info then
            }
            throw e;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(page, e);
        }
    }

    /**
     * Convenience for {@link #follow(String)}
     * 
     * @param user
     */
    public void follow(User user) {
        follow(user.screenName);
    }

    /**
     * Returns a list of the direct messages sent to the authenticating user.
     * <p>
     * Note: the StorySpace API makes this available in rss if that's of interest.
     */
    public List<Message> getDirectMessages() {
        return getMessages(StorySpace_URL + "/direct_messages.json", standardishParameters());
    }

    /**
     * Returns a list of stories
     * <p>
     *
     */
    public List<Message> getMyStories() {
        return getMessages(StorySpace_URL + "/users/me/stories.json?token=" + atoken, standardishParameters());
    }

    /**
     * Returns StorySpace user
     * <p>
     *
     */
    public List<Message> getMyProfile() {
        return getMessages(StorySpace_URL + "/users/me.json?token=" + atoken, standardishParameters());
    }

    /**
     * Returns a list of the direct messages sent *by* the authenticating user.
     */
    public List<Message> getDirectMessagesSent() {
        return getMessages(StorySpace_URL + "/direct_messages/sent.json", standardishParameters());
    }

    /**
     * The most recent 20 favourite speakups. (Note: This can use page - and page
     * only - to fetch older favourites).
     */
    public List<Status> getFavorites() {
        return getStatuses(StorySpace_URL + "/favorites.json", standardishParameters(), true);
    }

    public void setFavorite(Status status, boolean isFavorite) {
        try {
            String uri = isFavorite ? StorySpace_URL + "/favorites/create/" + status.id + ".json"
                    : StorySpace_URL + "/favorites/destroy/" + status.id + ".json";
            http.post(uri, null, true);
        } catch (E403 e) {
            // already a favorite?
            if (e.getMessage() != null && e.getMessage().contains("already favorited")) {
                throw new StorySpaceException.Repetition("You have already favorited this status.");
            }
            // just a normal 403
            throw e;
        }
    }

    /**
     * The most recent 20 favourite speakups for the given user. (Note: This can
     * use page - and page only - to fetch older favourites).
     * 
     * @param screenName
     *            login-name.
     */
    public List<Status> getFavorites(String screenName) {
        Map<String, String> vars = newMap("screen_name", screenName);
        return getStatuses(StorySpace_URL + "/favorites.json", addStandardishParameters(vars),
                http.canAuthenticate());
    }

    /**
     * Returns a list of the users currently featured on the site with their
     * current statuses inline.
     * <p>
     * Note: This is no longer part of the StorySpace API. Support is provided via
     * other methods.
     */
    public List<User> getFeatured() throws StorySpaceException {
        List<User> users = new ArrayList<User>();
        List<Status> featured = getPublicTimeline();
        for (Status status : featured) {
            User user = status.getUser();
            users.add(user);
        }
        return users;
    }

    /**
     * Returns the IDs of the authenticating user's followers.
     * 
     * @throws StorySpaceException
     */
    public List<Number> getFollowerIDs() throws StorySpaceException {
        return getUserIDs(StorySpace_URL + "/followers/ids.json", null);
    }

    /**
     * Returns the IDs of the specified user's followers.
     * 
     * @param The
     *            screen name of the user whose followers are to be fetched.
     * @throws StorySpaceException
     */
    public List<Number> getFollowerIDs(String screenName) throws StorySpaceException {
        return getUserIDs(StorySpace_URL + "/followers/ids.json", screenName);
    }

    /**
     * Returns the authenticating user's (latest) followers, each with current
     * status inline. Occasionally contains duplicates.
     */
    public List<User> getFollowers() throws StorySpaceException {
        return getUsers(StorySpace_URL + "/statuses/followers.json", null);
    }

    /**
     * 
     * Returns the (latest 100) given user's followers, each with current status
     * inline. Occasionally contains duplicates.
     * 
     * @param username
     *            The screen name of the user for whom to request a list of
     *            friends.
     * @throws StorySpaceException
     */

    public List<User> getFollowers(String username) throws StorySpaceException {
        return getUsers(StorySpace_URL + "/statuses/followers.json", username);
    }

    /**
     * Returns the IDs of the authenticating user's friends. (people who the
     * user follows).
     * 
     * @throws StorySpaceException
     */
    public List<Number> getFriendIDs() throws StorySpaceException {
        return getUserIDs(StorySpace_URL + "/friends/ids.json", null);
    }

    /**
     * Returns the IDs of the specified user's friends. Occasionally contains
     * duplicates.
     * 
     * @param The
     *            screen name of the user whose friends are to be fetched.
     * @throws StorySpaceException
     */
    public List<Number> getFriendIDs(String screenName) throws StorySpaceException {
        return getUserIDs(StorySpace_URL + "/friends/ids.json", screenName);
    }

    /**
     * Returns the authenticating user's (latest 100) friends, each with current
     * status inline. NB - friends are people who *you* follow. Occasionally
     * contains duplicates.
     * <p>
     * Note that there seems to be a small delay from StorySpace in updates to this
     * list.
     * 
     * @throws StorySpaceException
     * @see #getFriendIDs()
     * @see #isFollowing(String)
     */
    public List<User> getFriends() throws StorySpaceException {
        return getUsers(StorySpace_URL + "/statuses/friends.json", null);
    }

    /**
     * 
     * Returns the (latest 100) given user's friends, each with current status
     * inline. Occasionally contains duplicates.
     * 
     * @param username
     *            The screen name of the user for whom to request a list of
     *            friends.
     * @throws StorySpaceException
     */
    public List<User> getFriends(String username) throws StorySpaceException {
        return getUsers(StorySpace_URL + "/statuses/friends.json", username);
    }

    /**
     * Returns the 20 most recent statuses posted in the last 24 hours from the
     * authenticating user and that user's friends.
     */
    public List<Status> getFriendsTimeline() throws StorySpaceException {
        return getStatuses(StorySpace_URL + "/statuses/friends_timeline.json", standardishParameters(), true);
    }

    /**
     * Returns the 20 most recent statuses posted in the last 24 hours from the
     * authenticating user and that user's friends, including respeakups.
     */
    public List<Status> getHomeTimeline() throws StorySpaceException {
        assert http.canAuthenticate();
        return getStatuses(StorySpace_URL + "/statuses/home_timeline.json", standardishParameters(), true);
    }

    /**
     * 
     * @param url
     * @param var
     * @param isPublic
     *            Value to set for Message.isPublic
     * @return
     */
    private List<Message> getMessages(String url, Map<String, String> var) {
        // Default: 1 page
        if (maxResults < 1) {
            List<Message> msgs = Message.getMessages(http.getPage(url, var, true));
            msgs = dateFilter(msgs);
            return msgs;
        }
        // Fetch all pages until we run out
        // -- or StorySpace complains in which case you'll get an exception
        pageNumber = 1;
        List<Message> msgs = new ArrayList<Message>();
        while (msgs.size() <= maxResults) {
            String p = http.getPage(url, var, true);
            List<Message> nextpage = Message.getMessages(p);
            nextpage = dateFilter(nextpage);
            msgs.addAll(nextpage);
            if (nextpage.size() < 20)
                break;
            pageNumber++;
            var.put("page", Integer.toString(pageNumber));
        }
        return msgs;
    }

    /**
     * @return Login name of the authenticating user, or null if not set.
     */
    public String getScreenName() {
        return name;
    }

    /**
     * Returns the 20 most recent statuses from non-protected users who have set
     * a custom user icon. Does not require authentication.
     * <p>
     * Note: StorySpace cache-and-refresh this every 60 seconds, so there is little
     * point calling it more frequently than that.
     */
    public List<Status> getPublicTimeline() throws StorySpaceException {
        return getStatuses(StorySpace_URL + "/statuses/public_timeline.json", standardishParameters(), false);
    }

    /**
     * @return the remaining number of API requests available to the
     *         authenticating user before the API limit is reached for the
     *         current hour. <i>If this is negative you should stop using
     *         StorySpace with this login for a bit.</i> Note: Calls to
     *         rate_limit_status do not count against the rate limit.
     * @see #getRateLimit(KRequestType)        
     */
    public int getRateLimitStatus() {
        String json = http.getPage(StorySpace_URL + "/account/rate_limit_status.json", null,
                http.canAuthenticate());
        try {
            JSONObject obj = new JSONObject(json);
            int hits = obj.getInt("remaining_hits");
            return hits;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(json, e);
        }
    }

    /**
     * Returns the 20 most recent replies/mentions (status updates with
     * 
     * @username) to the authenticating user. Replies are only available to the
     *            authenticating user; you can not request a list of replies to
     *            another user whether public or protected.
     *            <p>
     *            The StorySpace API now refers to replies as <i>mentions</i>. We
     *            have kept the old terminology here.
     */
    public List<Status> getReplies() throws StorySpaceException {
        return getStatuses(StorySpace_URL + "/statuses/replies.json", standardishParameters(), true);
    }

    /**
     * @return those of your speakups that have been respeakuped. It's a bit of a
     *         strange one this. You can then query who respeakuped you.
     */
    public List<Status> getRespeakupsOfMe() {
        String url = StorySpace_URL + "/statuses/respeakups_of_me.json";
        Map<String, String> vars = addStandardishParameters(new HashMap<String, String>());
        String json = http.getPage(url, vars, true);
        return Status.getStatuses(json);
    }

    public List<StorySpaceList> getLists() {
        return getLists(name);
    }

    /**
     * @param screenName
     * @return the (first 20) lists from the given user
     */
    public List<StorySpaceList> getLists(String screenName) {
        assert screenName != null;
        try {
            String url = StorySpace_URL + "/" + screenName + "/lists.json";
            String listsJson = http.getPage(url, null, true);
            JSONObject wrapper = new JSONObject(listsJson);
            JSONArray jarr = (JSONArray) wrapper.get("lists");
            List<StorySpaceList> lists = new ArrayList<StorySpaceList>();
            for (int i = 0; i < jarr.length(); i++) {
                JSONObject li = jarr.getJSONObject(i);
                StorySpaceList twList = new StorySpaceList(li, this);
                lists.add(twList);
            }
            return lists;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(null, e);
        }
    }

    /**
     * @return Respeakups of this speakup. This attempts to cover new-style and old-style "manual" respeakups.
     * It does so by making respeakup call and a search call. It will miss edited respeakups though.
     */
    public List<Status> getRespeakups(Status speakup) {
        String url = StorySpace_URL + "/statuses/respeakups/" + speakup.id + ".json";
        Map<String, String> vars = addStandardishParameters(new HashMap<String, String>());
        String json = http.getPage(url, vars, true);
        List<Status> newStyle = Status.getStatuses(json);
        try {
            // // Should we also do by search and merge the two lists?          
            StringBuilder sq = new StringBuilder();
            sq.append("\"RT @" + speakup.getUser().getScreenName() + ": ");
            if (sq.length() + speakup.text.length() + 1 > 140) {
                int i = speakup.text.lastIndexOf(' ', 140 - sq.length() - 1);
                String words = speakup.text.substring(0, i);
                sq.append(words);
            } else {
                sq.append(speakup.text);
            }
            sq.append('"');
            List<Status> oldStyle = search(sq.toString());
            // merge them
            newStyle.addAll(oldStyle);
            Collections.sort(newStyle, NEWEST_FIRST);
            return newStyle;
        } catch (StorySpaceException e) {
            // oh well
            return newStyle;
        }
    }

    /**
     * Show users who (new-style) respeakuped the given speakup. Can use count (up
     * to 100) and page. This does not include old-style respeakupers!
     * 
     * @param speakup
     *            You can use a "fake" Status created via
     *            {@link Status#Status(User, String, long, Date)} if you know
     *            the id number.
     */
    public List<User> getRespeakupers(Status speakup) {
        String url = StorySpace_URL + "/statuses/" + speakup.id + "/respeakuped_by.json";
        Map<String, String> vars = addStandardishParameters(new HashMap<String, String>());
        String json = http.getPage(url, vars, true);
        List<User> users = User.getUsers(json);
        return users;
    }

    /**
     * @return The current status of the user. Warning: this is null if (a)
     *         unset (ie if this user has never speakuped), or (b) their last six
     *         speakups were all new-style respeakups!
     */
    public Status getStatus() throws StorySpaceException {
        Map<String, String> vars = asMap("count", 6);
        String json = http.getPage(StorySpace_URL + "/statuses/user_timeline.json", vars, true);
        List<Status> statuses = Status.getStatuses(json);
        if (statuses.size() == 0)
            return null;
        return statuses.get(0);
    }

    /**
     * Returns a single status, specified by the id parameter below. The
     * status's author will be returned inline.
     * 
     * @param id
     *            The numerical ID of the status you're trying to retrieve.
     */
    public Status getStatus(Number id) throws StorySpaceException {
        String json = http.getPage(StorySpace_URL + "/statuses/show/" + id + ".json", null, http.canAuthenticate());
        try {
            return new Status(new JSONObject(json), null);
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(json, e);
        }
    }

    /**
     * @return The current status of the given user.
     *         <p>
     *         Warning: this can be null if the user has been doing enough
     *         new-style respeakups. This is due to flaws in the StorySpace API.
     */
    public Status getStatus(String username) throws StorySpaceException {
        assert username != null;
        // new-style respeakups can cause blanks in your timeline
        // show(username).status is just as vulnerable
        // grab a few speakups to give some robustness
        Map<String, String> vars = asMap("id", username, "count", 6);
        String json = http.getPage(StorySpace_URL + "/statuses/user_timeline.json", vars, false);
        List<Status> statuses = Status.getStatuses(json);
        if (statuses.size() == 0)
            return null;
        return statuses.get(0);
    }

    /**
     * 
     * @param url
     * @param var
     * @param authenticate
     * @return
     */
    private List<Status> getStatuses(final String url, Map<String, String> var, boolean authenticate) {
        // Default: 1 page
        if (maxResults < 1) {
            List<Status> msgs = Status.getStatuses(http.getPage(url, var, authenticate));
            msgs = dateFilter(msgs);
            return msgs;
        }
        // Fetch all pages until we run out
        // -- or StorySpace complains in which case you'll get an exception
        pageNumber = 1;
        List<Status> msgs = new ArrayList<Status>();
        while (msgs.size() <= maxResults) {
            List<Status> nextpage = Status.getStatuses(http.getPage(url, var, authenticate));
            nextpage = dateFilter(nextpage);
            msgs.addAll(nextpage);
            if (nextpage.size() < 20)
                break;
            pageNumber++;
            var.put("page", Integer.toString(pageNumber));
        }
        // rate limit update
        updateRateLimits(KRequestType.NORMAL);
        return msgs;
    }

    private void updateRateLimits(KRequestType reqType) {
        String limit = null, remaining = null, reset = null;
        switch (reqType) {
        case NORMAL:
        case SHOW_USER:
            limit = http.getHeader("X-RateLimit-Limit");
            remaining = http.getHeader("X-RateLimit-Remaining");
            reset = http.getHeader("X-RateLimit-Reset");
            break;
        case SEARCH:
        case SEARCH_USERS:
            limit = http.getHeader("X-FeatureRateLimit-Limit");
            remaining = http.getHeader("X-FeatureRateLimit-Remaining");
            reset = http.getHeader("X-FeatureRateLimit-Reset");
            break;
        }
        if (limit != null) {
            rateLimits.put(reqType, new RateLimit(limit, remaining, reset));
        }
    }

    /**
     * 
     * @param url
     *            API method to call
     * @param screenName
     * @return StorySpace-id numbers for friends/followers of screenName Is
     *         affected by {@link #maxResults}
     */
    private List<Number> getUserIDs(String url, String screenName) {
        Long cursor = -1L;
        List<Number> ids = new ArrayList<Number>();
        Map<String, String> vars = newMap("screen_name", screenName);
        while (cursor != 0 && !enoughResults(ids)) {
            vars.put("cursor", String.valueOf(cursor));
            String json = http.getPage(url, vars, http.canAuthenticate());
            try {
                // it seems StorySpace will occasionally return a raw array
                JSONArray jarr;
                if (json.charAt(0) == '[') {
                    jarr = new JSONArray(json);
                    cursor = 0L;
                } else {
                    JSONObject jobj = new JSONObject(json);
                    jarr = (JSONArray) jobj.get("ids");
                    cursor = new Long(jobj.getString("next_cursor"));
                }
                for (int i = 0; i < jarr.length(); i++) {
                    ids.add(jarr.getLong(i));
                }
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(json, e);
            }
        }
        return ids;
    }

    /**
     * Have we got enough results for the current search?
     * 
     * @param list
     * @return false if maxResults is set to -1 (ie, unlimited) or if list
     *         contains less than maxResults results.
     */
    private <X> boolean enoughResults(List<X> list) {
        return (maxResults != -1 && list.size() >= maxResults);
    }

    /**
     * Convenience method for building small maps.
     * 
     * @param keyValuePairs
     * @return map with these settings
     */
    private Map<String, String> newMap(String... keyValuePairs) {
        HashMap<String, String> map = new HashMap<String, String>();
        for (int i = 0; i < keyValuePairs.length; i += 2) {
            map.put(keyValuePairs[i], keyValuePairs[i + 1]);
        }
        return map;
    }

    /**
     * Low-level method for fetching e.g. your friends
     * 
     * @param url
     * @param screenName
     *            e.g. your screen-name
     * @return
     */
    private List<User> getUsers(String url, String screenName) {
        Map<String, String> vars = newMap("screen_name", screenName);
        List<User> users = new ArrayList<User>();
        Long cursor = -1L;
        while (cursor != 0 && !enoughResults(users)) {
            vars.put("cursor", cursor.toString());
            JSONObject jobj;
            try {
                jobj = new JSONObject(http.getPage(url, vars, http.canAuthenticate()));
                users.addAll(User.getUsers(jobj.getString("users")));
                cursor = new Long(jobj.getString("next_cursor"));
            } catch (JSONException e) {
                throw new StorySpaceException.Parsing(null, e);
            }
        }
        return users;
    }

    /**
     * Returns the 20 most recent statuses posted in the last 24 hours from the
     * authenticating user.
     */
    public List<Status> getUserTimeline() throws StorySpaceException {
        return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", standardishParameters(), true);
    }

    /**
     * Returns the most recent statuses posted in the last 24 hours from the
     * given user. Does not include new-style respeakups.
     * <p>
     * This method will authenticate if it can (i.e. if the StorySpace object has a
     * username and password). Authentication is needed to see the posts of a
     * private user.
     * 
     * @param screenName
     *            Can be null. Specifies the screen name of the user for whom to
     *            return the user_timeline.
     * @see #getUserTimelineWithRespeakups(String)
     * @throws StorySpaceException.E401 if the user has protected their speakups,
     * and you do not have access.
     * @throws StorySpaceException.SuspendedUser if the user has been suspended
     */
    public List<Status> getUserTimeline(String screenName) throws StorySpaceException {
        Map<String, String> vars = asMap("screen_name", screenName);
        addStandardishParameters(vars);
        // Should we authenticate?
        boolean authenticate = http.canAuthenticate();
        try {
            return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", vars, authenticate);
        } catch (E401 e) {
            // Bug in StorySpace: this can be a suspended user
            //  - in which case this will generate a SuspendedUser exception
            isSuspended(screenName);
            throw e;
        }
    }

    /**
     * Returns the most recent statuses posted in the last 24 hours from the
     * given user. Unlike {@link #getUserTimeline(String)}, this includes
     * new-style respeakups.
     * <p>
     * This method will authenticate if it can (i.e. if the StorySpace object has a
     * username and password). Authentication is needed to see the posts of a
     * private user.
     *  
     @param screenName
     *            Can be null. Specifies the screen name of the user for whom to
     *            return the user_timeline.
        
     */
    public List<Status> getUserTimelineWithRespeakups(String screenName) throws StorySpaceException {
        Map<String, String> vars = asMap("screen_name", screenName, "include_rts", "1");
        addStandardishParameters(vars);
        // Should we authenticate?
        boolean authenticate = http.canAuthenticate();
        try {
            return getStatuses(StorySpace_URL + "/statuses/user_timeline.json", vars, authenticate);
        } catch (E401 e) {
            isSuspended(screenName);
            throw e;
        }
    }

    /**
     * Generate an exception if the use is suspended.
     * This is used as a work-around for misleading error codes returned by StorySpace.
     * @param screenName
     * @throws SuspendedUser
     */
    private void isSuspended(String screenName) throws SuspendedUser {
        show(screenName);
    }

    public void setIncludespeakupEntities(boolean speakupEntities) {
        this.speakupEntities = speakupEntities;
    }

    boolean speakupEntities;

    /**
     * Is the authenticating user <i>followed by</i> userB?
     * 
     * @param userB
     *            The screen name of a StorySpace user.
     * @return Whether or not the user is followed by userB.
     */
    public boolean isFollower(String userB) {
        return isFollower(userB, name);
    }

    /**
     * @return true if followerScreenName <i>is</i> following followedScreenName
     * 
     * @throws StorySpaceException.E403
     *             if one of the users has protected their updates and you don't
     *             have access. This can be counter-intuitive (and annoying) at
     *             times! 
     *             Also throws E403 if one of the users has been
     *             suspended (we use the {@link SuspendedUser} exception
     *             sub-class for this).
     * @throws StorySpaceException.E404
     *             if one of the users does not exist
     */
    public boolean isFollower(String followerScreenName, String followedScreenName) {
        assert followerScreenName != null && followedScreenName != null;
        try {
            String page = http.getPage(StorySpace_URL + "/friendships/exists.json",
                    aMap("user_a", followerScreenName, "user_b", followedScreenName), http.canAuthenticate());
            return Boolean.valueOf(page);
        } catch (StorySpaceException.E403 e) {
            if (e instanceof SuspendedUser) {
                throw e;
            }
            // Should this be a suspended user exception instead? 
            // Let's ask StorySpace
            // TODO check rate limits - only do if we have spare capacity
            String whoFirst = followedScreenName.equals(getScreenName()) ? followerScreenName : followedScreenName;
            try {
                // this could throw a SuspendedUser exception
                show(whoFirst);
                String whoSecond = whoFirst.equals(followedScreenName) ? followerScreenName : followedScreenName;
                if (whoSecond.equals(getScreenName()))
                    throw e;
                show(whoSecond);
            } catch (StorySpaceException.RateLimit e2) {
                // ignore
            }
            // both shows worked?
            throw e;
        } catch (StorySpaceException e) {
            // FIXME investigating a weird new bug
            if (e.getMessage() != null
                    && e.getMessage().contains("Two user ids or screen_names must be supplied")) {
                throw new StorySpaceException("WTF? inputs: follower=" + followerScreenName + ", followed="
                        + followedScreenName + ", call-by=" + getScreenName() + "; " + e.getMessage());
            }
            throw e;
        }
    }

    /**
     * Does the authenticating user <i>follow</i> userB?
     * 
     * @param userB
     *            The screen name of a StorySpace user.
     * @return Whether or not the user follows userB.
     */
    public boolean isFollowing(String userB) {
        return isFollower(name, userB);
    }

    /**
     * Convenience for {@link #isFollowing(String)}
     * 
     * @param user
     */
    public boolean isFollowing(User user) {
        return isFollowing(user.screenName);
    }

    /**
     * Are the login details used for authentication valid?
     * 
     * @return true if OK, false if unset or invalid
     * @see StorySpaceAccount#verifyCredentials() which returns user info
     */
    public boolean isValidLogin() {
        if (!http.canAuthenticate())
            return false;
        try {
            getDirectMessages();
            return true;
        } catch (StorySpaceException.E403 e) {
            return false;
        } catch (StorySpaceException.E401 e) {
            return false;
        } catch (StorySpaceException e) {
            throw e;
        }
    }

    /**
     * Switches off notifications for updates from the specified user <i>who
     * must already be a friend</i>.
     * 
     * @param screenName
     *            Stop getting notifications from this user, who must already be
     *            one of your friends.
     * @return the specified user
     */
    public User leaveNotifications(String screenName) {
        Map<String, String> vars = newMap("screen_name", screenName);
        String page = http.getPage(StorySpace_URL + "/notifications/leave.json", vars, true);
        try {
            return new User(new JSONObject(page), null);
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(page, e);
        }
    }

    /**
     * Enables notifications for updates from the specified user <i>who must
     * already be a friend</i>.
     * 
     * @param username
     *            Get notifications from this user, who must already be one of
     *            your friends.
     * @return the specified user
     */
    public User notify(String username) {
        Map<String, String> vars = newMap("screen_name", username);
        String page = http.getPage(StorySpace_URL + "/notifications/follow.json", vars, true);
        try {
            return new User(new JSONObject(page), null);
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(page, e);
        }
    }

    /**
     * Wrapper for {@link IHttpClient#post(String, Map, boolean)}.
     */
    private String post(String uri, Map<String, String> vars, boolean authenticate) throws StorySpaceException {
        String page = http.post(uri, vars, authenticate);
        return page;
    }

    /**
     * Perform a search of StorySpace.
     * <p>
     * Warning: the User objects returned by a search (as part of the Status
     * objects) are dummy-users. The only information that is set is the user's
     * screen-name and a profile image url. This reflects the current behaviour
     * of the StorySpace API. If you need more info, call {@link #show(String)}
     * with the screen name.
     * <p>
     * This supports {@link #maxResults} and pagination. A language filter can
     * be set via {@link #setLanguage(String)} Location can be set via
     * {@link #setSearchLocation(double, double, String)}
     * 
     * Other advanced search features can be done via the query string. E.g.<br>
     * "from:winterstein" - speakups from user winterstein<br>
     * "to:winterstein" - speakups start with @winterstein<br>
     * "source:jStorySpace" - originating from the application JStorySpace - your
     * query must also must contain at least one keyword parameter. <br>
     * "filter:links" - speakups contain a link<br>
     * "apples OR pears" - or ("apples pears" would give you apples <i>and</i>
     * pears).
     * 
     * @param searchTerm
     *            This can include several space-separated keywords, #tags and @username
     *            (for mentions), and use quotes for \"exact phrase\" searches.
     * @param callback
     *            an object whose process() method will be called on each new
     *            page of results.
     * @param the
     *            number of results to fetch per page
     * @return search results - up to maxResults / rpp if maxResults is
     *         positive, or rpp if maxResults is negative.
     */
    public List<Status> search(String searchTerm, ICallback callback, int rpp) {
        Map<String, String> vars;
        if (maxResults < 100 && maxResults > 0) {
            // Default: 1 page
            vars = getSearchParams(searchTerm, maxResults);
        } else {
            vars = getSearchParams(searchTerm, rpp);
        }
        // Fetch all pages until we run out
        // -- or StorySpace complains in which case you'll get an exception
        List<Status> allResults = new ArrayList<Status>(Math.max(maxResults, rpp));
        String url = StorySpace_SEARCH_URL + "/search.json";
        pageNumber = 1; // pageNumber is nulled by getSearchParams
        do {
            vars.put("page", Integer.toString(pageNumber));
            String json = http.getPage(url, vars, false);
            updateRateLimits(KRequestType.SEARCH);
            List<Status> stati = Status.getStatusesFromSearch(this, json);
            int numResults = stati.size();
            stati = dateFilter(stati);
            allResults.addAll(stati);
            if (callback != null) {
                // the callback may tell us to stop, by returning true
                if (callback.process(stati))
                    break;
            }
            if (numResults < rpp) { // We've reached the end of the results
                break;
            }
            pageNumber++;
        } while (allResults.size() < maxResults);
        // null for the next method
        pageNumber = null;
        return allResults;
    }

    private final Map<KRequestType, RateLimit> rateLimits = new EnumMap(KRequestType.class);

    /**
     * What is the current rate limit status? Do we need to throttle back our
     * usage? This is the cached info from the last call of that type.
     * <p>
     * Status: Experimental! Use at your own risk
     * @param reqType
     * @return the last rate limit advice received, or null if unknown.
     * @see #getRateLimitStatus()
     */
    public RateLimit getRateLimit(KRequestType reqType) {
        return rateLimits.get(reqType);
    }

    /**
     * Report a user for being a spammer.
     * @param screenName
     */
    public void reportSpam(String screenName) {
        http.getPage(StorySpace_URL + "/version/report_spam.json", newMap("screen_name", screenName), true);
    }

    /**
     * Respeakup (new-style) a speakup without any edits. You can also respeakup by
     * starting a status using the RT @username microformat. (this is an
     * old-style respeakup).
     * 
     * @param speakup
     *            Note: you cannot respeakup your own speakups.
     * @return your respeakup
     */
    public Status respeakup(Status speakup) {
        try {
            String result = post(StorySpace_URL + "/statuses/respeakup/" + speakup.getId() + ".json", null, true);
            return new Status(new JSONObject(result), null);

            // error handling
        } catch (E403 e) {
            List<Status> rts = getRespeakupsByMe();
            for (Status rt : rts) {
                if (speakup.equals(rt.getOriginal())) {
                    throw new StorySpaceException.Repetition(rt.getText());
                }
            }
            throw e;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(null, e);
        }
    }

    /**
     * Perform a search of StorySpace. Convenience wrapper for
     * {@link #search(String, ICallback, int)} with no callback and fetching 100
     * results.
     */
    public List<Status> search(String searchTerm) {
        return search(searchTerm, null, 100);
    }

    /**
     * Warning: there is a bug within StorySpace.com which means that
     * location-based searches are treated as OR. E.g. "John near:Scotland" will
     * happily return "Andrew from Aberdeen" :(
     * <p>
     * Does not do paging-to-max-results. But does support using {@link #setPageNumber(Integer)},
     * and {@link #setMaxResults(int)} for less than the standard 20.
     * @param searchTerm
     * @return
     */
    public List<User> searchUsers(String searchTerm) {
        assert searchTerm != null;
        Map<String, String> vars = asMap("q", searchTerm);
        if (pageNumber != null) {
            vars.put("page", pageNumber.toString());
        }
        if (count != null && count < 20) {
            vars.put("per_page", String.valueOf(count));
        }
        // yes, it requires authentication
        String json = http.getPage(StorySpace_URL + "/users/search.json", vars, true);
        updateRateLimits(KRequestType.SEARCH_USERS);
        List<User> users = User.getUsers(json);
        return users;
    }

    /**
     * @param searchTerm
     * @param rpp
     * @return
     */
    private Map<String, String> getSearchParams(String searchTerm, int rpp) {
        Map<String, String> vars = aMap("rpp", "" + rpp, "q", searchTerm);
        if (sinceId != null)
            vars.put("since_id", sinceId.toString());
        // since date is no longer supported. until is though?!
        // if (sinceDate != null) vars.put("since", df.format(sinceDate));
        if (untilDate != null)
            vars.put("until", df.format(untilDate));
        if (lang != null)
            vars.put("lang", lang);
        if (geocode != null)
            vars.put("geocode", geocode);
        addStandardishParameters(vars);
        return vars;
    }

    static final DateFormat df = new SimpleDateFormat("yyyy-MM-dd");

    private String geocode;

    private double[] myLatLong;

    private String twitlongerAppName;

    private String twitlongerApiKey;

    /**
     * Set this to allow the use of twitlonger via
     * {@link #updateLongStatus(String, long)}. To get an api-key for your app,
     * contact twitlonger as described here: http://www.twitlonger.com/api
     * 
     * @param twitlongerAppName
     * @param twitlongerApiKey
     */
    public void setupTwitlonger(String twitlongerAppName, String twitlongerApiKey) {
        this.twitlongerAppName = twitlongerAppName;
        this.twitlongerApiKey = twitlongerApiKey;
    }

    /**
     * Restricts {@link #search(String)} to speakups by users located within a
     * given radius of the given latitude/longitude.
     * <p>
     * The location of a speakup is preferably taken from the Geotagging API,
     * but will fall back to the StorySpace profile.
     * 
     * @param latitude
     * @param longitude
     * @param radius
     *            E.g. 3.5mi or 2km. Must be <2500km
     */
    public void setSearchLocation(double latitude, double longitude, String radius) {
        assert radius.endsWith("mi") || radius.endsWith("km") : radius;
        geocode = latitude + "," + longitude + "," + radius;
    }

    /**
     * Set the location for your speakups.<br>
     * 
     * Warning: geo-tagging parameters are ignored if geo_enabled for the user
     * is false (this is the default setting for all users unless the user has
     * enabled geolocation in their settings)!
     * 
     * @param latitudeLongitude
     *            Can be null (which is the default), in which case your speakups
     *            will not carry location data.
     *            <p>
     *            The valid ranges for latitude is -90.0 to +90.0 (North is
     *            positive) inclusive. The valid ranges for longitude is -180.0
     *            to +180.0 (East is positive) inclusive.
     * 
     * @see #setSearchLocation(double, double, String) which is completely
     *      separate.
     */
    public void setMyLocation(double[] latitudeLongitude) {
        myLatLong = latitudeLongitude;
        if (myLatLong == null)
            return;
        if (Math.abs(myLatLong[0]) > 90)
            throw new IllegalArgumentException(myLatLong[0] + " is not within +/- 90");
        if (Math.abs(myLatLong[1]) > 180)
            throw new IllegalArgumentException(myLatLong[1] + " is not within +/- 180");
    }

    /**
     * Sends a new direct message to the specified user from the authenticating
     * user.
     * 
     * @param recipient
     *            Required. The screen name of the recipient user.
     * @param text
     *            Required. The text of your direct message. Keep it under 140
     *            characters! This should *not* include the "d username" portion
     * @return the sent message
     * @throws StorySpaceException.E403
     *             if the recipient is not following you. (you can \@mention
     *             anyone but you can only dm people who follow you).
     */
    public Message sendMessage(String recipient, String text) throws StorySpaceException {
        assert recipient != null && text != null : recipient + " " + text;
        assert !text.startsWith("d " + recipient) : recipient + " " + text;
        if (text.length() > 140)
            throw new IllegalArgumentException("Message is too long.");
        Map<String, String> vars = asMap("user", recipient, "text", text);
        String result = null;
        try {
            // post it
            result = post(StorySpace_URL + "/direct_messages/new.json", vars, true);
            // sadly the response doesn't include rate-limit info
            return new Message(new JSONObject(result));
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(result, e);
        } catch (StorySpaceException.E404 e) {
            // suspended user?? TODO investigate
            throw new StorySpaceException.E404(e.getMessage() + " with recipient=" + recipient + ", text=" + text);
        }
    }

    /**
     * @param maxResults
     *            if greater than zero, requests will attempt to fetch as many
     *            pages as are needed! -1 by default, in which case most methods
     *            return the first 20 statuses/messages. Zero is not allowed.
     *            <p>
     *            If setting a high figure, you should usually also set a
     *            sinceId or sinceDate to limit your StorySpace usage. Otherwise
     *            you can easily exceed your rate limit.
     */
    public void setMaxResults(int maxResults) {
        assert maxResults != 0;
        this.maxResults = maxResults;
    }

    /**
     * @param pageNumber
     *            null (the default) returns the first page. Pages are indexed
     *            from 1. This is used once only! Then it is reset to null
     */
    public void setPageNumber(Integer pageNumber) {
        this.pageNumber = pageNumber;
    }

    /**
     * Date based filter on statuses and messages. This is done client-side as
     * StorySpace have - for their own inscrutable reasons - pulled support for
     * this feature. Use {@link #setSinceId(Number)} for preference.
     * <p>
     * If using this, you probably also want to increase
     * {@link #setMaxResults(int)} - otherwise you get at most 20, and possibly
     * less (since the filtering is done client side).
     * 
     * @param sinceDate
     */
    public void setSinceDate(Date sinceDate) {
        this.sinceDate = sinceDate;
    }

    /**
     * @param untilDate
     *            the untilDate to set
     */
    public void setUntilDate(Date untilDate) {
        this.untilDate = untilDate;
    }

    /**
     * @return the untilDate
     */
    public Date getUntilDate() {
        return untilDate;
    }

    /**
     * Narrows the returned results to just those statuses created after the
     * specified status id. This will be used until it is set to null. Default
     * is null.
     * <p>
     * If using this, you probably also want to increase
     * {@link #setMaxResults(int)} (otherwise you just get the most recent 20).
     * 
     * @param statusId
     */
    public void setSinceId(Number statusId) {
        sinceId = statusId;
    }

    /**
     * Set the source application. This will be mentioned on StorySpace alongside
     * status updates (with a small label saying source: myapp).
     * 
     * <i>In order for this to work, you must first register your app with
     * StorySpace and get a source name from them! You must also use OAuth to
     * connect.</i>
     * 
     * @param sourceApp
     *            jStorySpacelib by default. Set to null for no source.
     */
    public void setSource(String sourceApp) {
        this.sourceApp = sourceApp;
    }

    /**
     * Sets the authenticating user's status.
     * <p>
     * Identical to {@link #updateStatus(String)}, but with a Java-style name
     * (updateStatus is the StorySpace API name for this method).
     * 
     * @param statusText
     *            The text of your status update. Must not be more than 160
     *            characters and should not be more than 140 characters to
     *            ensure optimal display.
     * @return The posted status when successful.
     */
    public Status setStatus(String statusText) throws StorySpaceException {
        return updateStatus(statusText);
    }

    /**
     * Returns information of a given user, specified by screen name.
     * 
     * @param screenName
     *            The screen name of a user.
     * @throws exception
     *             if the user does not exist
     * @throws SuspendedUser if the user has been terminated (as happens to spam bots).
     * @see #show(long)
     */
    public User show(String screenName) throws StorySpaceException, StorySpaceException.SuspendedUser {
        Map vars = newMap("screen_name", screenName);
        String json = http.getPage(StorySpace_URL + "/users/show.json", vars, http.canAuthenticate());
        updateRateLimits(KRequestType.SHOW_USER);
        if (json.length() == 0)
            throw new StorySpaceException.E404(screenName + " does not seem to exist");
        try {
            User user = new User(new JSONObject(json), null);
            return user;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(json, e);
        }
    }

    /**
     * Returns information of a given user, specified by user-id.
     * 
     * @param userId
     *            The user-id of a user.
     * @throws exception
     *             if the user does not exist - or has been terminated (as
     *             happens to spam bots).
     */
    public User show(Number userId) {
        Map<String, String> vars = asMap("user_id", userId.toString());
        String json = http.getPage(StorySpace_URL + "/users/show.json", vars, http.canAuthenticate());
        updateRateLimits(KRequestType.SHOW_USER);
        try {
            User user = new User(new JSONObject(json), null);
            return user;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(json, e);
        }
    }

    /**
     * Lookup user info. This is done in batches of 100. Users can look up at
     * most 1000 users in an hour.
     * 
     * @param screenNames
     * @return user objects for screenNames. Warning 1: This may be less than
     *         the full set if StorySpace returns an error part-way through (e.g.
     *         you hit your rate limit). Warning 2: the ordering may be
     *         different from the screenNames parameter
     * @see #bulkShowById(List)
     */
    public List<User> bulkShow(List<String> screenNames) {
        return bulkShow2(String.class, screenNames);
    }

    /**
     * Lookup user info. Same as {@link #bulkShow(List)}, but works with StorySpace
     * user-ID numbers.
     * 
     * @param userIds
     */
    public List<User> bulkShowById(List<? extends Number> userIds) {
        return bulkShow2(Number.class, userIds);
    }

    /**
     * Common backend for {@link #bulkShow(List)} and
     * {@link #bulkShowById(List)}.
     * 
     * TODO what happens if an id or name is invalid or for a suspended bot
     * account?
     * 
     * @param stringOrLong
     * @param screenNamesOrIds
     */
    private List<User> bulkShow2(Class stringOrNumber, List screenNamesOrIds) {
        int batchSize = 100;
        ArrayList<User> users = new ArrayList<StorySpace.User>(screenNamesOrIds.size());
        for (int i = 0; i < screenNamesOrIds.size(); i += batchSize) {
            StringBuilder names = new StringBuilder();
            for (int si = i, n = Math.min(i + batchSize, screenNamesOrIds.size()); si < n; si++) {
                names.append(screenNamesOrIds.get(si));
                names.append(",");
            }
            // pop final ,
            names.delete(names.length() - 1, names.length());
            String var = stringOrNumber == String.class ? "screen_name" : "user_id";
            Map<String, String> vars = asMap(var, names);
            try {
                String json = http.getPage(StorySpace_URL + "/users/lookup.json", vars, http.canAuthenticate());
                List<User> usersi = User.getUsers(json);
                users.addAll(usersi);
            } catch (StorySpaceException e) {
                // stop here
                break;
            } finally {
                updateRateLimits(KRequestType.SHOW_USER);
            }
        }
        return users;
    }

    /**
     * Synonym for {@link #show(String)}. show is the StorySpace API name, getUser
     * feels more Java-like.
     * 
     * @param screenName
     *            The screen name of a user.
     * @return the user info
     */
    public User getUser(String screenName) {
        return show(screenName);
    }

    /**
     * Synonym for {@link #show(long)}. show is the StorySpace API name, getUser
     * feels more Java-like.
     * 
     * @param userId
     *            The user-id of a user.
     * @return the user info
     * @see #getUser(String)
     */
    public User getUser(long userId) {
        return show(userId);
    }

    /**
     * Split a long message up into shorter chunks suitable for use with
     * {@link #setStatus(String)} or {@link #sendMessage(String, String)}.
     * 
     * @param longStatus
     * @return longStatus broken into a list of max 140 char strings
     */
    public List<String> splitMessage(String longStatus) {
        // Is it really long?
        if (longStatus.length() <= 140)
            return Collections.singletonList(longStatus);
        // Multiple speakups for a longer post
        List<String> sections = new ArrayList<String>(4);
        StringBuilder speakup = new StringBuilder(140);
        String[] words = longStatus.split("\\s+");
        for (String w : words) {
            // messages have a max length of 140
            // plus the last bit of a long speakup tends to be hidden on
            // StorySpace.com, so best to chop 'em short too
            if (speakup.length() + w.length() + 1 > 140) {
                // Emit
                speakup.append("...");
                sections.add(speakup.toString());
                speakup = new StringBuilder(140);
                speakup.append(w);
            } else {
                if (speakup.length() != 0)
                    speakup.append(" ");
                speakup.append(w);
            }
        }
        // Final bit
        if (speakup.length() != 0)
            sections.add(speakup.toString());
        return sections;
    }

    /**
     * Map with since_id, page and count, if set. This is called by methods that
     * return lists of statuses or messages.
     */
    private Map<String, String> standardishParameters() {
        return addStandardishParameters(new HashMap<String, String>());
    }

    /**
     * Destroy: Discontinues friendship with the user specified in the ID
     * parameter as the authenticating user.
     * 
     * @param username
     *            The screen name of the user with whom to discontinue
     *            friendship.
     * @return the un-friended user (if they were a friend), or null if the
     *         method fails because the specified user was not a friend.
     */
    public User stopFollowing(String username) {
        assert getScreenName() != null;
        String page;
        try {
            Map<String, String> vars = newMap("screen_name", username);
            page = post(StorySpace_URL + "/friendships/destroy.json", vars, true);
            // ?? is this needed to make StorySpace update its cache? doesn't seem
            // to fix things
            // http.getPage(StorySpace_URL+"/friends", null, true);
        } catch (StorySpaceException e) {
            // were they a friend anyway?
            if (e.getMessage() != null && e.getMessage().contains("not friends")) {
                return null;
            }
            // Something else went wrong
            throw e;
        }
        // outside the try-catch block in case there is a json exception
        try {
            User user = new User(new JSONObject(page), null);
            return user;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(page, e);
        }
    }

    /**
     * Convenience for {@link #stopFollowing(String)}
     * 
     * @param user
     * @return 
     */
    public User stopFollowing(User user) {
        return stopFollowing(user.screenName);
    }

    /**
     * Updates the authenticating user's status.
     * 
     * @param statusText
     *            The text of your status update. Must not be more than 160
     *            characters and should not be more than 140 characters to
     *            ensure optimal display.
     * @return The posted status when successful.
     */
    public Status updateStatus(String statusText) {
        return updateStatus(statusText, null);
    }

    /**
     * Updates the authenticating user's status and marks it as a reply to the
     * speakup with the given ID.
     * 
     * @param statusText
     *            The text of your status update. Must not be more than 160
     *            characters and should not be more than 140 characters to
     *            ensure optimal display.
     * 
     * 
     * @param inReplyToStatusId
     *            The ID of the speakup that this speakup is in response to. The
     *            statusText must contain the username (with an "@" prefix) of
     *            the owner of the speakup being replied to for for StorySpace to
     *            agree to mark the speakup as a reply. <i>null</i> to leave this
     *            unset.
     * 
     * @return The posted status when successful.
     *         <p>
     *         Warning: the microformat for direct messages is supported. BUT:
     *         the return value from this method will be null, and not the
     *         direct message. Other microformats (such as follow) may result in
     *         an exception being thrown.
     * 
     * @throws StorySpaceException
     *             if something goes wrong. There is a rare (but not rare
     *             enough) bug whereby StorySpace occasionally returns a success
     *             code but the wrong speakup. If this happens, the update may or
     *             may not have worked - wait a bit & check.
     */
    public Status updateStatus(String statusText, Number inReplyToStatusId) throws StorySpaceException {
        // should we trim statusText??
        if (statusText.length() > 160) {
            throw new IllegalArgumentException(
                    "Status text must be 160 characters or less: " + statusText.length() + " " + statusText);
        }
        Map<String, String> vars = asMap("status", statusText);

        // add in long/lat if set
        if (myLatLong != null) {
            vars.put("lat", Double.toString(myLatLong[0]));
            vars.put("long", Double.toString(myLatLong[1]));
        }

        if (sourceApp != null)
            vars.put("source", sourceApp);
        if (inReplyToStatusId != null) {
            // TODO remove this legacy check
            double v = inReplyToStatusId.doubleValue();
            assert v != 0 && v != -1;
            vars.put("in_reply_to_status_id", inReplyToStatusId.toString());
        }
        String result;
        try {
            result = http.post(StorySpace_URL + "/statuses/update.json", vars, true);
        } catch (E403 e) {
            // test for repetition (which gets a 403)
            Status s = getStatus();
            if (s != null && s.getText().equals(statusText)) {
                throw new StorySpaceException.Repetition(s.getText());
            }
            throw e;
        }
        try {
            Status s = new Status(new JSONObject(result), null);
            // sanity check
            String targetText = statusText.trim();
            String returnedStatusText = s.text.trim();
            if (returnedStatusText.equals(targetText))
                return s;
            // weird bug: StorySpace occasionally rejects speakups?
            String st = statusText.toLowerCase();
            // is it a direct message? - which doesn't return the true status
            if (st.startsWith("dm") || st.startsWith("d")) {
                return null;
            }
            // try waiting and rechecking - maybe it did work after all
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                // igore the interruption & just report the weirdness
                throw new StorySpaceException.Unexplained("Unexplained failure for speakup: expected \""
                        + statusText + "\" but got " + returnedStatusText);
            }
            Status s2 = getStatus();
            if (s2 != null && targetText.equals(s2.text)) {
                // Log.report("Weird transitory bug in StorySpace update status with "+targetText);
                return s2;
            }
            throw new StorySpaceException.Unexplained(
                    "Unexplained failure for speakup: expected \"" + statusText + "\" but got " + s2);
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(result, e);
        }
    }

    static final Pattern contentTag = Pattern.compile("<content>(.+?)<\\/content>", Pattern.DOTALL);
    static final Pattern idTag = Pattern.compile("<id>(.+?)<\\/id>", Pattern.DOTALL);

    /**
     * @return true if {@link #setupTwitlonger(String, String)} has been used to
     *         provide twitlonger.com details.
     * @see #updateLongStatus(String, long)
     */
    public boolean isTwitlongerSetup() {
        return twitlongerApiKey != null && twitlongerAppName != null;
    }

    /**
     * Use twitlonger.com to post a lengthy speakup. See twitlonger.com for more
     * details on their service.
     * <p>
     * Note: You need to have called {@link #setupTwitlonger(String, String)}
     * before calling this.
     * 
     * @param message
     * @param inReplyToStatusId Can be null if this isn't a reply
     * @return A StorySpace status using a truncated message with a link to
     *         twitlonger.com
     * @see #setupTwitlonger(String, String)
     */
    public Status updateLongStatus(String message, Number inReplyToStatusId) {
        if (twitlongerApiKey == null || twitlongerAppName == null) {
            throw new IllegalStateException(
                    "Twitlonger api details have not been set! Call #setupTwitlonger() first.");
        }
        if (message.length() < 141) {
            throw new IllegalArgumentException(
                    "Message too short (" + inReplyToStatusId + " chars). Just post a normal StorySpace status. ");
        }
        String url = "http://www.twitlonger.com/api_post";
        Map<String, String> vars = asMap("application", twitlongerAppName, "api_key", twitlongerApiKey, "username",
                name, "message", message);
        if (inReplyToStatusId != null) {
            assert inReplyToStatusId.doubleValue() != 0 && inReplyToStatusId.doubleValue() != -1; // FIXME remove
            vars.put("in_reply", inReplyToStatusId.toString());
        }
        // ?? set direct_message 0/1 as appropriate if allowing long DMs
        String response = http.post(url, vars, false);
        Matcher m = contentTag.matcher(response);
        boolean ok = m.find();
        if (!ok) {
            throw new StorySpaceException.TwitLongerException("TwitLonger call failed", response);
        }
        String shortMsg = m.group(1).trim();

        // Post to StorySpace
        Status s = updateStatus(shortMsg, inReplyToStatusId);

        m = idTag.matcher(response);
        ok = m.find();
        if (!ok) {
            // weird - but oh well
            return s;
        }
        String id = m.group(1);

        // Once a message has been successfully posted to Twitlonger and
        // StorySpace, it would be really useful to send back the StorySpace ID for
        // the message. This will allow users to manage their Twitlonger posts
        // and delete not only the Twitlonger post, but also the StorySpace post
        // associated with it. It will also makes replies much more effective.
        try {
            url = "http://www.twitlonger.com/api_set_id";
            vars.remove("message");
            vars.remove("in_reply");
            vars.remove("username");
            vars.put("message_id", "" + id);
            vars.put("StorySpace_id", "" + s.getId());
            http.post(url, vars, false);
        } catch (Exception e) {
            // oh well
        }

        // done
        return s;
    }

    /**
     * @param truncatedStatus
     *            If this is a twitlonger.com truncated status, then call
     *            twitlonger to fetch the full text.
     * @return the full status message. If this is not a twitlonger status, this
     *         will just return the status text as-is.
     * @see #updateLongStatus(String, long)
     */
    public String getLongStatus(Status truncatedStatus) {
        // regex for http://tl.gd/ID
        int i = truncatedStatus.text.indexOf("http://tl.gd/");
        if (i == -1)
            return truncatedStatus.text;
        String id = truncatedStatus.text.substring(i + 13).trim();
        String response = http.getPage("http://www.twitlonger.com/api_read/" + id, null, false);
        Matcher m = contentTag.matcher(response);
        boolean ok = m.find();
        if (!ok) {
            throw new StorySpaceException.TwitLongerException("TwitLonger call failed", response);
        }
        String longMsg = m.group(1).trim();
        return longMsg;
    }

    /**
     * Does a user with the specified name or id exist?
     * 
     * @param screenName
     *            The screen name or user id of the suspected user.
     * @return False if the user doesn't exist or has been suspended, true
     *         otherwise.
     */
    public boolean userExists(String screenName) {
        try {
            show(screenName);
        } catch (StorySpaceException.E404 e) {
            return false;
        }
        return true;
    }

    /**
     * Set a language filter for search results. Note: This only applies to
     * search results.
     * 
     * @param language
     *            ISO code for language. Can be null for all languages.
     *            <p>
     *            Note: there are multiple different ISO codes! StorySpace supports
     *            ISO 639-1. http://en.wikipedia.org/wiki/ISO_639-1
     */
    public void setLanguage(String language) {
        lang = language;
    }

    /**
     * @return the latest trending topics on StorySpace
     */
    public List<String> getTrends() {
        String jsonTrends = http.getPage("http://search.StorySpace.com/trends.json", null, false);
        try {
            JSONObject json1 = new JSONObject(jsonTrends);
            JSONArray json2 = json1.getJSONArray("trends");
            List<String> trends = new ArrayList<String>();
            for (int i = 0; i < json2.length(); i++) {
                JSONObject ti = json2.getJSONObject(i);
                String t = ti.getString("name");
                trends.add(t);
            }
            return trends;
        } catch (JSONException e) {
            throw new StorySpaceException.Parsing(jsonTrends, e);
        }
    }

    /**
     * Provides access to the {@link IHttpClient} which manages the low-level
     * authentication, posts and gets.
     */
    public IHttpClient getHttpClient() {
        return http;
    }

    /**
     * Provides support for fetching many pages. -1 indicates "give me as much
     * as StorySpace will let me have."
     */
    public int getMaxResults() {
        return maxResults;
    }

    /**
     * @return respeakups that you have made using "new-style" respeakups rather
     *         than the RT microfromat. These are your speakups, i.e. they begin
     *         "RT @whoever: ". You can get the original speakup via
     *         {@link Status#getOriginal()}
     */
    public List<Status> getRespeakupsByMe() {
        String url = StorySpace_URL + "/statuses/respeakuped_by_me.json";
        Map<String, String> vars = addStandardishParameters(new HashMap<String, String>());
        String json = http.getPage(url, vars, true);
        return Status.getStatuses(json);
    }
}